Adding Keyright to a .NET app looks like one line: client.Validate() returns a LicenseInfo, you check IsValid, done. That line is the easy part, and it is not where licensing goes wrong. Licensing goes wrong in the architecture around that call — where it lives, how often it runs, what you keep between runs, and above all what happens when it cannot give you a clean answer. Get that wrong in one direction and a transient hiccup locks out a customer who paid you. Get it wrong in the other and a tampered binary runs your Pro features for free.
This post builds a license gate: a single object that owns the one validation call, caches an immutable snapshot of the result, and makes one deliberate decision per failure reason about whether to fail closed or fail open. It is the piece most licensing tutorials skip, and it is the piece that decides whether your licensing is an asset or a support queue.
One call, one owner, one snapshot
The first rule is that exactly one object calls the SDK. Scatter Validate() through your view models and you get re-verification on every button click, inconsistent answers when the clock ticks past expiry mid-session, and no single place to change the policy. Instead, validate once, freeze the answer into a snapshot, and let the rest of the app read the snapshot.
public sealed record LicenseSnapshot(
bool AllowsPaid,
Edition Edition,
EntitlementSet Entitlements,
LicenseStatus Status,
DateTime TakenUtc)
{
public static readonly LicenseSnapshot Free =
new(false, Edition.Free, EntitlementSet.Empty, LicenseStatus.NoLicense, DateTime.UtcNow);
}
The snapshot is a record for a reason: it is immutable, so once the gate hands it out, no feature can accidentally mutate licensing state, and a background refresh swaps the whole reference atomically rather than editing fields other threads are reading.
The gate itself holds the KeyrightClient, the current snapshot, and the policy that turns a raw LicenseInfo into a snapshot:
public sealed class LicenseGate
{
private readonly KeyrightClient _client;
private volatile LicenseSnapshot _current = LicenseSnapshot.Free;
public LicenseGate(KeyrightClient client) => _client = client;
public LicenseSnapshot Current => _current;
public bool Allows(string feature) =>
_current.AllowsPaid && _current.Entitlements.IsEnabled(feature);
public long Limit(string feature, long fallback) =>
_current.AllowsPaid ? _current.Entitlements.GetLimit(feature, fallback) : fallback;
public void Refresh()
{
LicenseInfo info;
try
{
info = _client.Validate(); // offline: verifies signature + lease locally
}
catch (Exception ex)
{
// A thrown exception is an *unknown* state, not a licensed one.
_current = LicenseSnapshot.Free with { Status = LicenseStatus.Malformed };
Log.Warning(ex, "license validation threw; treating as unlicensed");
return;
}
_current = Decide(info);
}
}
Note the volatile field and the whole-object swap: a reader on the UI thread always sees either the old snapshot or the new one, never a half-updated struct. And note that a thrown exception resolves to the free edition, never to "licensed." An unknown licensing state is never a paid one — that principle is the whole post in one line.
The decision table is the gate
LicenseInfo.Status is not a boolean. Keyright reports eight distinct outcomes, and collapsing them to IsValid throws away exactly the information you need to choose a reaction:
public enum LicenseStatus
{
Valid = 0, // signature + lease verified, not expired, machine matches
NoLicense = 1, // nothing to verify
Malformed = 2, // found a license artifact but could not parse it
SignatureInvalid = 3, // parsed, but the signature does not match the embedded key
Expired = 4, // valid signature, past its expiry
MachineMismatch = 5, // valid, but node-locked to a different machine
Revoked = 6, // explicitly revoked server-side and seen in a lease refresh
ClockTampered = 7, // local clock rolled back relative to a trusted timestamp
}
The gate's policy is a single switch over these, and writing it forces you to make each fail-open / fail-closed call explicitly rather than by accident:
private static LicenseSnapshot Decide(LicenseInfo info)
{
switch (info.Status)
{
// Clean pass — the only state that grants paid features.
case LicenseStatus.Valid:
return new(true, info.Edition, info.Entitlements(),
info.Status, DateTime.UtcNow);
// Blameless absence — this is a free user, not an attacker. Fail OPEN to free.
case LicenseStatus.NoLicense:
return LicenseSnapshot.Free;
// A subscription that lapsed. ExpiryUtc is real and signed; honor a grace
// window so a renewal-in-flight or a lease that has not refreshed yet does
// not lock out a paying customer mid-session. Fail OPEN, briefly and bounded.
case LicenseStatus.Expired when WithinGrace(info, days: 14):
return new(true, info.Edition, info.Entitlements(),
info.Status, DateTime.UtcNow);
case LicenseStatus.Expired:
return LicenseSnapshot.Free;
// Everything below is evidence of tampering or a license that was never
// yours to run. There is no benign reading. Fail CLOSED, hard.
case LicenseStatus.Malformed:
case LicenseStatus.SignatureInvalid:
case LicenseStatus.MachineMismatch:
case LicenseStatus.Revoked:
case LicenseStatus.ClockTampered:
default:
return LicenseSnapshot.Free with { Status = info.Status };
}
}
private static bool WithinGrace(LicenseInfo info, int days) =>
info.ExpiryUtc is { } exp && DateTime.UtcNow <= exp.AddDays(days);
The shape of the policy is the point. Three of the eight states are blameless: Valid grants, NoLicense is just a free user, and a freshly Expired subscription keeps running through a bounded grace window so a renewal that is still propagating never produces a lockout. The other five — Malformed, SignatureInvalid, MachineMismatch, Revoked, ClockTampered — have no benign interpretation, so they drop straight to free with no grace. You are not being hostile to customers by failing those closed; you are refusing to extend trust to an artifact that failed to prove it deserves any.
Why ClockTampered must fail closed even though it looks harmless
The one status that tempts developers to fail open is ClockTampered. It feels like an honest mistake — a laptop with a dead CMOS battery, a VM restored from a snapshot, a user in the wrong timezone. Why punish that?
Because the clock is the only thing standing between a time-limited license and an infinite one. Keyright records a trusted high-water timestamp each time it sees a monotonically advancing clock (on activation, on each lease refresh). ClockTampered means the local clock is now behind that high-water mark — the wall clock went backwards. The benign explanations are real, but so is the attack: roll the clock back to last month and a trial that expired yesterday is young again. A gate that fails open on ClockTampered has no expiry at all, because any expiry can be undone by setting the date back. So the gate fails closed and the recovery path is for the user to fix their clock and relaunch — a verifiable action — rather than for your code to guess which rollbacks are honest.
Refreshing without a network dependency
The snapshot is taken once at startup and then on a slow timer. Two things matter here. First, the refresh is offline — Validate() re-verifies the signed license and the cached lease against the embedded public key with no network call, so a refresh works on a plane. Second, the lease is what makes a long-lived process safe: activation obtained a signed, machine-bound lease good for 14 days, and as long as the lease is unexpired the gate keeps returning Valid without ever reaching your issuing service.
// In a WPF/WinForms app: a low-frequency timer, not a hot-path hook.
var timer = new System.Threading.Timer(_ => gate.Refresh(),
null, dueTime: TimeSpan.Zero, period: TimeSpan.FromHours(6));
// Also refresh after the machine wakes, where the clock and lease may have moved.
SystemEvents.PowerModeChanged += (_, e) =>
{
if (e.Mode == PowerModes.Resume) gate.Refresh();
};
The only time the network enters the picture is a lease refresh — when the lease is close to its expiry, the SDK renews it against your issuing service on its next online moment. If the service is unreachable, the lease simply rides out its remaining grace; the gate keeps saying Valid the whole time and only drops when the lease truly lapses with no renewal. Downtime on your side therefore degrades gracefully into offline operation, not an outage for the customer — which is only true because the gate reads a cached, signed lease instead of phoning home on every check.
Gating features, not just the app
With the gate in place, per-feature gating is a one-liner, and because entitlements are signed into the license you ship one binary for every tier:
// Boolean capability.
exportButton.IsEnabled = gate.Allows("export-to-pdf");
// Numeric quota — "unlimited" in the signed payload reads back as long.MaxValue.
int maxSeats = (int)Math.Min(gate.Limit("seats", fallback: 1), int.MaxValue);
// A whole Pro-only pane, bound once to the snapshot.
proPanel.Visibility = gate.Current.AllowsPaid ? Visibility.Visible : Visibility.Collapsed;
Everything funnels through the gate, so there is exactly one place that knows the policy, one place that caches, and one place to audit when someone asks "what happens if the license is revoked while the app is running?" The answer is: on the next refresh the status becomes Revoked, Decide maps it to free, the snapshot swaps atomically, and the Pro pane collapses — no inline check anywhere else in the codebase had to know.
The one rule
If you remember one thing, make it the invariant the gate enforces everywhere: an unknown licensing state is never a licensed one. A thrown exception, an unparseable artifact, a bad signature, a clock that went backwards — none of those are "probably fine." They are the absence of proof, and the absence of proof fails closed. The handful of states that do fail open — a missing license, a subscription inside its grace window — are the ones you can name a blameless reason for out loud. Everything else drops to free and waits for the user to present a license that verifies. That single rule, applied in one gate instead of a hundred scattered checks, is the difference between licensing you can trust and licensing you have to babysit.
Top comments (0)