Licensing an MSIX-packaged .NET app: AppData virtualization, the uninstall that deletes your lease cache, and where a Keyright license should actually live
MSIX is how Windows desktop apps get installed now — through winget, through enterprise deployment, through the Store — and it quietly changes three things a licensing SDK depends on. Writes to %LocalAppData% are redirected into a per-package folder the app never sees in a path. Uninstalling the package deletes that folder, so the cached activation lease, the local trial clock and the clock-tamper state disappear while the seat on the licensing service stays bound. And the install directory is read-only, which is exactly where a bundled site license should sit. This post walks through what the Keyright SDK writes and where it lands under a packaged app, why the machine fingerprint survives when the cache does not, what an uninstall/reinstall cycle does to seat accounting (nothing, thanks to idempotent activation — unless the customer meant to move), how to detect package identity and give the SDK explicit paths, how to ship a license inside the package, when to disable write virtualization in the manifest, and the test loop that proves it.
Ask a .NET desktop developer where their app keeps its settings and they will say %LocalAppData%. Ask what happens to those files when the app is uninstalled and they will say “nothing, that is the user’s data”. Both answers are right for an MSI. Both are wrong for an MSIX package — and MSIX is now how desktop apps arrive on Windows machines, whether through winget install, an Intune deployment, or the Store.
That matters to licensing more than to almost anything else an app writes, because a licensing SDK keeps exactly the kind of small, important files whose location and lifetime you need to understand: a signed activation lease that lets the app run offline, a local timestamp that stops a trial from being restarted, a record of the last time the clock was seen. This post looks at what the Keyright SDK writes, where those bytes actually land inside a packaged app, what an uninstall does to them, and how to make the whole thing explicit instead of letting a file-system filter decide.
What the SDK writes, and where it thinks it is writing it
A Keyright client is initialised once with the product name and the embedded public key, and from then on it keeps up to three files for that product:
{LocalApplicationData}\Keyright\{product}\lease.json signed activation lease (cached after ActivateAsync)
{LocalApplicationData}\Keyright\{product}\state.json first-seen / last-seen timestamps (trial clock, clock-tamper)
{LocalApplicationData}\Keyright\{product}\license.json default location for an offline license file
There is a fourth, {LocalApplicationData}\Keyright\machine.id, which only exists as a last resort: the machine fingerprint is anchored on the OS machine GUID, and the random persisted id is written only if that GUID cannot be read at all. On a Windows machine it normally never exists.
Every one of those paths comes from Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), and that call returns the same thing inside an MSIX package as outside it: C:\Users\dana\AppData\Local. The SDK, your app, and anything that logs client.LeaseCachePath will all report that path. It is just not where the file is.
Where it actually goes
A desktop app packaged with the runFullTrust capability runs inside a lightweight container, and one of the things that container does is file-system write virtualization for the user’s AppData folders. When the process writes to %LocalAppData%\Keyright\acme-studio\lease.json, a filter driver redirects the write to
%LocalAppData%\Packages\<PackageFamilyName>\LocalCache\Local\Keyright\acme-studio\lease.json
The redirect is invisible to the process. File.WriteAllText succeeds, File.Exists returns true, Directory.EnumerateFiles lists the file — all against the virtual path, which the container presents as a merged view: reads check the package’s LocalCache first and fall through to the real %LocalAppData% if the file is not there, and writes always land in LocalCache (copy-on-write if the real file existed). Open File Explorer at C:\Users\dana\AppData\Local\Keyright and there is nothing in it; the files are two levels down inside Packages.
Three consequences fall out of that, and they are the whole reason this post exists.
The lease survives package updates. LocalCache belongs to the package family, not the package version. When winget upgrade installs 2.4 over 2.3, the folder is untouched, the cached lease is still there, and the app starts licensed. Calling ActivateAsync at startup — the pattern the SDK recommends anyway — refreshes it silently when the machine is online.
The lease does not survive uninstall. Removing the package removes %LocalAppData%\Packages\<family> with everything in it. lease.json, state.json, a license.json the user pasted into the default location: gone. This is the behaviour an MSI never had, and it is the one that surprises people.
The machine fingerprint is unaffected by either. The anchor the fingerprint is built on is HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid, which belongs to the Windows installation, sits outside the package, and is read — not written — by the SDK. HKLM reads are not virtualized; the package’s virtual registry overlays only the keys the package itself carries. The machine name and OS version, the two softer signals, are likewise not package state. So the fingerprint the app presents to the licensing service is byte-for-byte the same before an uninstall and after a reinstall.
What uninstall and reinstall do to the seat
Put the last two consequences together and the seat accounting follows.
The seat is not a file on the machine. It is a record on the licensing service that says this license, this machineId. When the customer uninstalls the package, nothing tells the service anything — the lease cache is deleted locally, the seat stays bound. When they reinstall and enter their key again, the app fingerprints the same machine, sends the same machineId, and the service does what it does for any machine it has already seen: refreshes the existing activation and returns a fresh lease. Re-activation on the same machine is idempotent. No second seat is consumed, the dashboard still shows one device, and the customer never knew there was a question.
The case that does go wrong is the one where the customer uninstalled because they were leaving the machine. They retire a laptop, set up a new one, install the package, enter the key — and the new laptop is a new fingerprint. If the license had one seat, the service answers LicenseStatus.SeatLimit: all seats in use, free one first. The old laptop cannot free it; the app is no longer on it.
Two things handle that, and neither is specific to MSIX, but MSIX makes them matter more because uninstalling is so much cleaner than it used to be:
- Give the user a deactivate in the app.
DeactivateAsync(key)releases this machine’s seat on the service and clears the local lease. Put it where a user who is about to retire a machine will find it — a “Move this license to another computer” item next to “Enter license key” — and say what it does. A packaged app has no uninstall hook you can run code from, so the deactivate has to happen before the uninstall, which means the UI has to invite it. - Free dead machines vendor-side. The license’s Devices panel in the Keyright dashboard lists every bound machine with a Free seat action, and
POST /admin/licenses/{id}/free-seatdoes the same from a support script. The customer’s next activation takes the freed seat.
What you should not do is try to reconstruct a deactivation from the uninstall. There is no package-level uninstall script in MSIX by design, and a licensing call that runs during uninstall would be the least reliable network request in your product.
Know you are packaged, and say where the files go
Relying on the redirect is fine until a support engineer asks for lease.json and spends twenty minutes in the wrong folder. The cleaner approach is to detect package identity at startup and hand the SDK explicit paths inside the package’s own data folder, so the path the app logs is the path the bytes are in.
Package identity comes from GetCurrentPackageFullName in kernel32. Outside a package it returns APPMODEL_ERROR_NO_PACKAGE (15700); inside one it returns the package full name.
using System.Runtime.InteropServices;
using System.Text;
public static class PackageContext
{
private const int APPMODEL_ERROR_NO_PACKAGE = 15700;
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true)]
private static extern int GetCurrentPackageFullName(ref int length, StringBuilder? fullName);
/// <summary>The package full name when running packaged; null when unpackaged or not on Windows.</summary>
public static string? FullName { get; } = Resolve();
public static bool IsPackaged => FullName is not null;
private static string? Resolve()
{
if (!OperatingSystem.IsWindowsVersionAtLeast(10)) return null;
int length = 0;
int rc = GetCurrentPackageFullName(ref length, null); // ERROR_INSUFFICIENT_BUFFER when packaged
if (rc == APPMODEL_ERROR_NO_PACKAGE) return null;
var sb = new StringBuilder(length);
rc = GetCurrentPackageFullName(ref length, sb);
return rc == 0 ? sb.ToString() : null;
}
}
With that, the client is built in one of two shapes. Unpackaged, the SDK defaults are right. Packaged, point every path at ApplicationData.Current.LocalFolder — the package’s LocalState folder, which is the documented home for a packaged app’s data, is never virtualized because it is already package-private, survives updates, and is deleted on uninstall exactly like LocalCache:
using Keyright;
using Windows.Storage; // needs a Windows TFM, e.g. net8.0-windows10.0.19041.0
static KeyrightClient CreateLicenseClient()
{
var options = new KeyrightOptions
{
Product = "acme-studio",
PublicKeyBase64 = AcmeKeys.LicensePublicKey, // public key only; safe to ship in the package
ServiceUrl = "https://licensing.acme.example",
};
if (PackageContext.IsPackaged)
{
// Explicit, package-private, visible in Explorer at the path we log.
string root = Path.Combine(ApplicationData.Current.LocalFolder.Path, "keyright");
options.LeaseCachePath = Path.Combine(root, "lease.json");
options.LocalStatePath = Path.Combine(root, "state.json");
options.DefaultLicensePath = Path.Combine(root, "license.json");
}
return KeyrightClient.Initialize(options);
}
The SDK creates the directory on first write, so there is nothing else to set up. If your project targets plain net8.0-windows without a Windows SDK version, GetCurrentPackageFamilyName from the same DLL gives you the family name, and LocalState lives at %LocalAppData%\Packages\<family>\LocalState.
Then add the diagnostic that turns the support ticket into a one-liner — the same idea as the --site-info command in the site-license post: print what the gate will see, from the code path the gate uses.
// acme-studio --license-info
Console.WriteLine($"Packaged : {(PackageContext.IsPackaged ? PackageContext.FullName : "no")}");
Console.WriteLine($"Lease cache : {License.LeaseCachePath} (exists: {File.Exists(License.LeaseCachePath)})");
Console.WriteLine($"Local state : {License.LocalStatePath}");
Console.WriteLine($"License file : {License.DefaultPath}");
Console.WriteLine($"Machine id : {MachineFingerprint.Current().ToBoundString()}");
The machine id line is the one a support engineer needs to match a device in the dashboard, and it prints the same value packaged or not — which is the point.
A shared PC: two users, one seat
MSIX packages are registered per user. On a shared workstation where Dana and Sam both use the app, each has their own Packages\<family> folder, their own LocalCache, their own lease. Each of them has to activate once. That sounds like two seats and it is not: both activations fingerprint the same machine, send the same machineId, and the service binds one seat and refreshes it twice. The per-user cache is a convenience for the lease; the seat was always per machine.
The one thing to avoid is “fixing” this by moving the lease to %ProgramData% so both users share one file. Writes outside the user’s AppData are not virtualized, so it would work, but you then own the ACLs — a folder Dana’s installer created is not writable by Sam — for no gain, because the seat count is already right.
Shipping a license inside the package
Not every license is activated by the user. Site licenses, OEM builds and air-gapped deployments ship a signed license file with the product, and a package is the right vehicle for it: the install directory under C:\Program Files\WindowsApps\<PackageFullName> is read-only, signed as part of the package, and AppContext.BaseDirectory resolves there at runtime.
<!-- Acme.Studio.csproj: bundle the signed site license as content -->
<ItemGroup>
<Content Include="licenses\license.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
// Site/OEM build: the license is part of the package. Explicit file path = authoritative.
options.LicenseFilePath = Path.Combine(AppContext.BaseDirectory, "licenses", "license.json");
LicenseFilePath is an explicit source in the SDK’s precedence rules: when it is set, the ambient sources — the KEYRIGHT_LICENSE environment variable, a config path, the default AppData path — are not consulted, and a missing or invalid file is a NoLicense or SignatureInvalid result rather than a fall-through. That is what you want for a build whose licensing is a deployment decision rather than a user one. The two signatures do different jobs: the package signature says the bytes came from you; the license signature, verified against the public key you embedded, says the claims came from you. The read-only install directory keeps anyone from editing the file in place, but that is a convenience — it is the signature that makes the edit pointless.
For a mixed product — most customers activate with a key, enterprise customers get a site file — leave LicenseFilePath unset in the retail package and let the site customers drop the file at DefaultPath, which under the explicit-paths setup above is a stable, documentable location inside LocalState.
The trial that lives in a file
One Keyright behaviour deserves a specific MSIX note. A trial activated online carries an expiry the service wrote at issuance; the clock is on the service and an uninstall changes nothing. A trial shipped as an offline signed license file with trialDays and no baked-in expiry is timed from first use on this machine, and the first-seen timestamp that anchors it is kept in state.json. Under a packaged app that file lives in the package’s data folder, and uninstall deletes it — so an uninstall/reinstall cycle restarts the local clock.
The fix is to not keep the trial clock in package-scoped storage. Either issue trials online so the expiry is fixed by the service, or point LocalStatePath at a per-user location that is not inside Packages\<family> — which, in a virtualized app, means disabling write virtualization for that write or choosing a non-AppData path. The post on trials that don’t leak covers the general shape; the MSIX-specific lesson is just that “the user’s AppData” is not where the file is.
When to turn virtualization off
You can tell Windows not to redirect AppData writes for your package:
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:desktop6="http://schemas.microsoft.com/appx/manifest/desktop/windows10/6"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="desktop6 rescap">
<Properties>
<desktop6:FileSystemWriteVirtualization>disabled</desktop6:FileSystemWriteVirtualization>
<desktop6:RegistryWriteVirtualization>disabled</desktop6:RegistryWriteVirtualization>
</Properties>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
<rescap:Capability Name="unvirtualizedResources" />
</Capabilities>
</Package>
With that in place, %LocalAppData%\Keyright\acme-studio\lease.json is a real file at the real path, it survives uninstall, and an unpackaged build of the same product reads the same cache. That last point is the only strong reason to do it: if you ship both an MSI and an MSIX edition, or a support tool that inspects the licensing files directly, a shared unvirtualized location keeps them consistent. Otherwise the default is better — a cache that dies with the package is exactly the right lifetime for a cache, the seat survives it anyway, and unvirtualizedResources is a restricted capability that the Store will ask you to justify (sideloaded and winget packages can declare it without ceremony).
Verify it
The whole argument above is testable on one machine in a few minutes, and it is worth doing once before you ship the first packaged build.
# 1. Install the package and activate from inside the app. Then find the cache.
$pkg = Get-AppxPackage -Name Acme.Studio
Get-ChildItem "$env:LOCALAPPDATA\Packages\$($pkg.PackageFamilyName)\LocalState\keyright"
# → lease.json, state.json. Dashboard: the license shows 1 device, your machine id.
# 2. Upgrade in place; the files and the activation must survive.
winget upgrade Acme.Studio
Get-ChildItem "$env:LOCALAPPDATA\Packages\$($pkg.PackageFamilyName)\LocalState\keyright"
# 3. Uninstall; the folder is gone. Dashboard: still 1 device — nothing told it.
Remove-AppxPackage -Package $pkg.PackageFullName
Test-Path "$env:LOCALAPPDATA\Packages\$($pkg.PackageFamilyName)" # False
# 4. Reinstall and activate again. Dashboard: still 1 device, same machine id, lease refreshed.
Add one more step if you offer an in-app deactivate: run it before step 3 and confirm the dashboard shows 0 devices before the uninstall, then 1 again after step 4. (For debugging only, Remove-AppxPackage -PreserveApplicationData keeps the data folder through an uninstall, which is handy when you want to test the update path without re-activating.)
And the test that catches the trial case, if you ship offline trial files: activate a trialDays trial, note the expiry the app shows, uninstall, reinstall, and confirm the expiry is the same — which it will be only if state.json outlived the package or the trial clock is on the service.
What to take away
MSIX moves your app’s AppData without telling your app, deletes it on uninstall, and leaves the machine itself alone. For licensing that is almost entirely good news: the cached lease is a cache and behaves like one, the fingerprint anchor is not package state so a reinstall is the same machine, and idempotent activation means the customer who reinstalls never burns a seat. The two things to design for are the customer who uninstalls to move — give them a deactivate they can find — and the one file that should not be a cache, the local trial clock. Make the paths explicit, print them in a diagnostic, and the day a support engineer needs lease.json they will find it on the first try.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.