Skip to content
← All posts
· Delta1 Labs Licensing.NETGuide

Storing an activated license on the client: DPAPI, tamper-evident caching, and what not to trust

Activation gives the client a signed lease; then the question nobody plans for arrives — where do you put it? Drop it in a plain file and a user edits the expiry; encrypt it badly and you have tied the app to one machine by accident. The cache is for availability, not authenticity: the signature still decides whether a lease is real, so the store's only jobs are to survive a restart, resist casual editing, and refuse a lease that was copied from another machine. Here is how to seal a cached lease with DPAPI where it exists, bind it to the device with an HMAC so an edited or transplanted cache is detected, and why you must never trust the cached expiry over the signed one.

Activation is the part everyone designs carefully: the client proves itself, the server issues a signed lease, the app verifies it and runs. Then the lease has to live somewhere, and that is the part that quietly undoes the careful design. Write it to a plain file and a user edits the expiry out a decade. Copy-protect it clumsily and you have bound the app to one machine by accident, so a reinstall locks out a paying customer. The storage layer looks like plumbing and behaves like policy.

The clarifying idea is this: the cache is for availability, not authenticity. The signed lease is already tamper-evident — change a claim and the signature stops verifying. So the local store has exactly three jobs: survive a restart so the app starts offline, resist casual editing so a user cannot trivially fiddle with it, and refuse a lease transplanted from another machine. It does not decide whether the lease is valid; the signature does. This post is how Keyright persists an activated lease: sealing it with DPAPI where it exists, binding it to the device with an HMAC, and the one rule that keeps the whole thing honest — never trust the cached expiry over the signed one.

The shape of what you store

After activation you hold a signed lease — claims plus a signature — and some metadata about when to refresh it. Store the lease verbatim; everything else is a hint:

public sealed record CachedLease(
    string  SignedLease,      // the lease exactly as issued: claims + signature, opaque to the client
    DateTimeOffset CachedAt,  // when we wrote it — a REFRESH hint, never an authority on validity
    string  DeviceTag);       // HMAC over SignedLease, keyed by the device fingerprint

Notice what is not here: no plaintext expiresOn, no tier, no isValid boolean. The moment you store a convenient unsigned copy of a claim, you have created something a user can edit and something your code might be tempted to trust. The only claim-bearing field is SignedLease, and you read claims out of it only after verifying the signature.

Seal it at rest with DPAPI (where it exists)

On Windows, ProtectedData encrypts the blob with a key derived from the current user’s credentials. That buys confidentiality at rest and a weak device/user binding for free — the same bytes will not decrypt as another user or on another machine:

static byte[] Protect(byte[] plaintext) =>
    OperatingSystem.IsWindows()
        ? ProtectedData.Protect(plaintext, optionalEntropy: null, DataProtectionScope.CurrentUser)
        : KeychainOrFingerprintEncrypt(plaintext);   // Linux/macOS fallback

static byte[] Unprotect(byte[] sealed_) =>
    OperatingSystem.IsWindows()
        ? ProtectedData.Unprotect(sealed_, optionalEntropy: null, DataProtectionScope.CurrentUser)
        : KeychainOrFingerprintDecrypt(sealed_);

ProtectedData is Windows-only — on Linux and macOS it throws PlatformNotSupportedException, so the fallback uses the OS keychain or a key derived from the device fingerprint. Be honest about what this layer is: it is confidentiality at rest, not authenticity. A user with their own login can still decrypt their own cache. That is fine, because encryption was never the thing stopping them from editing a lease — the signature is.

Bind the copy to the device

DPAPI’s device binding is incidental and Windows-only; make it explicit and cross-platform with an HMAC over the stored lease, keyed by a value derived from the device fingerprint. On load, recompute it from this machine’s fingerprint and compare:

static string DeviceTag(string signedLease, DeviceFingerprint fp)
{
    using var h = new HMACSHA256(fp.DeriveKey());          // key bound to this device
    var mac = h.ComputeHash(Encoding.UTF8.GetBytes(signedLease));
    return Convert.ToBase64String(mac);
}

static bool BelongsHere(CachedLease c, DeviceFingerprint fp) =>
    CryptographicOperations.FixedTimeEquals(
        Convert.FromBase64String(c.DeviceTag),
        Convert.FromBase64String(DeviceTag(c.SignedLease, fp)));

Copy the whole cache file to another machine and the fingerprint differs, so the recomputed tag will not match c.DeviceTag; BelongsHere returns false, you discard the cache, and the app re-activates — which the server can refuse if the new machine is over its seat count. Use FixedTimeEquals, not ==, so the comparison does not leak timing. This is not unbreakable: an attacker who also replicates the fingerprint inputs defeats it. The point is to turn “copy one file” into “clone the device identity too,” the exact cost increase node-locking exists to impose.

The load path, in the only order that is safe

Put the three jobs in sequence and the single rule — authenticity comes from the signature, never the cache — falls out naturally:

1 · read + DPAPI-unprotect the file2 · recompute device HMAC, compare3 · verify the lease SIGNATURE4 · read expiry + tier from CLAIMSthe verified lease — the source of truthany gate fails →discard cache,re-activate online
public LicenseClaims LoadOrActivate(DeviceFingerprint fp)
{
    if (TryReadCache(out var raw) &&
        TryUnprotect(raw, out var cached) &&          // gate 1: at-rest seal intact
        BelongsHere(cached, fp))                       // gate 2: this machine
    {
        if (_keySet.TryVerify(cached.SignedLease, out var claims))  // gate 3: authenticity
            return claims;                             // gate 4: trust CLAIMS, not the file
    }
    return Activate(fp);   // any failure: re-activate; the server enforces seats + revocation
}

The rule that keeps it honest

Read the expiry you enforce from claims — the output of a successful signature verification — and nowhere else. The cache’s own CachedAt is useful only to decide when to refresh, never whether the license is valid. The instant you compare “now” against an unsigned timestamp stored next to the blob, you have handed the user a field to edit. Date rollback is its own attack with its own defence (trusting the clock); the storage layer’s contribution is simply to never introduce an unsigned expiry for anyone to tamper with.

The same discipline applies to failure. When any gate fails, you do not patch the cache — you discard it and re-activate. Re-activation is the moment the server gets to apply seat limits and revocation, so a transplanted or edited cache does not just fail locally; it routes the client back through the one check that can say “no.”

What to take away

An activated license has to persist, and the store is where careful licensing quietly leaks if you let it. Keep the cache to its real job — availability, not authenticity. Store the signed lease verbatim with no convenient unsigned copies of its claims. Seal it at rest with DPAPI on Windows (and a keychain or fingerprint-derived key elsewhere), bind the copy to the device with an HMAC so a transplanted file is detected, and verify the signature before you read a single claim. Enforce the expiry from the verified claims, treat the file’s timestamps as refresh hints only, and on any failure discard and re-activate so the server stays the authority on seats and revocation. Do that and the storage layer stops being the soft spot under an otherwise solid activation design.

Try Nebula.NET

Harden your .NET code in minutes — start with the free edition.