An online license check buys you things an offline one cannot: live seat counts, instant revocation, usage metering. But it comes with a failure mode that is easy to build in by accident. If your code treats every failure to reach the issuing service as a failed validation, then the first time your self-hosted Keyright instance is briefly unreachable — a DNS blip, a rolling deploy, a proxy that drops a connection — every paying customer is gated at once. A thirty-second infrastructure event becomes a thirty-second customer-facing outage, and the support tickets do not care that the license was valid the whole time.

The fix is not to stop checking online. It is to stop conflating "the service said no" with "I could not reach the service," and to ride out the second case on a signed, cached result for a bounded grace window.

Key takeaway: Cache the last-good validation locally, signed so it cannot be forged. When the service is unreachable, keep honouring the cached result for a configurable grace window bounded by the service's own signed timestamps (so nobody extends it by unplugging). When the service is reachable and says the license is revoked or expired, fail closed immediately. Log every cached run, and fail closed cleanly when the window is exhausted.

The two failures that must not look alike

Every online revalidation ends in one of three places, and the whole design turns on keeping them separate.

  • Confirmed. The service answered and the license is good. Renew the cached lease and carry on.
  • Definitive no. The service answered and the answer is revoked, expired, or seat withdrawn. This is final. You must fail closed now — grace does not apply, because the license genuinely is not valid any more.
  • Unreachable. The service did not answer at all: a connect timeout, a DNS failure, a 502 from a reverse proxy mid-deploy. You learned nothing about the license. The last thing the service told you still stands, so you keep honouring it — for a while.

The mistake is to collapse the second and third cases into one "validation failed" branch. A revocation and a dropped TCP connection are opposite facts. One means the customer should stop; the other means your network hiccuped and the customer should not notice.

Configuring the grace window

Grace is configured once, where you initialise the client. GracePeriod is how long a cached-valid license keeps working after the last successful confirmation while the service is unreachable. MaxOfflineDuration is the hard ceiling on how long any single cached lease is ever honoured, regardless of how many times grace would otherwise reset — it is your backstop against a machine that goes offline and stays there.

static readonly KeyrightClient License = KeyrightClient.Initialize(new KeyrightOptions { Product = "acme-suite", PublicKeyBase64 = "MIIBIjANBgkq...", // ships in your binary; not a secret ServiceUrl = "https://keyright.example.com", // your self-hosted instance GracePeriod = TimeSpan.FromHours(72), // ride out outages this long after last contact MaxOfflineDuration = TimeSpan.FromDays(14), // absolute ceiling on cached operation }); 

Pick GracePeriod to comfortably exceed your worst realistic transient event — a long deploy, a cert rotation, a regional network blip — but not so long that a genuinely cancelled customer runs for weeks. Seventy-two hours covers a Friday-evening incident that nobody looks at until Monday. MaxOfflineDuration is the longer, coarser limit; set it to your billing cadence so that a machine which never phones home eventually has to.

Refreshing: where the transient-vs-definitive split lives

Validate() stays exactly what it has always been: a pure, offline check of the cached lease against your embedded public key, which never throws and never touches the network. The online work happens in RefreshAsync, and its result is where you distinguish the two failures.

RefreshResult refresh = await License.RefreshAsync(ct); switch (refresh.Status) { case RefreshStatus.Confirmed: // Service reachable, license good. SDK has already renewed the signed // cached lease (new ConfirmedAtUtc), so the grace window resets. break; case RefreshStatus.Revoked: case RefreshStatus.Expired: case RefreshStatus.SeatWithdrawn: // Service reachable and said no. Definitive — DO NOT enter grace. GateApplication(refresh.Reason); break; case RefreshStatus.ServiceUnreachable: // We learned nothing. The cached lease still stands within its grace window. _log.LogWarning("Keyright unreachable ({Detail}); running on cached validation.", refresh.Detail); break; } 

If you prefer exceptions on a lower-level path, the connectivity failure surfaces as a typed exception you can catch, while a definitive rejection does not — it comes back as a result you must inspect:

try { await License.RefreshAsync(ct); // throwOnUnreachable: true overload } catch (KeyrightServiceUnreachableException ex) { // Transient only: DNS, connect timeout, 5xx from a proxy. Keep the cached lease. _log.LogWarning(ex, "Transient Keyright outage; grace window active."); } 

The rule to hold onto: a KeyrightServiceUnreachableException (or RefreshStatus.ServiceUnreachable) is the only thing that routes into grace. A revoked or expired result is never a connectivity problem, so it never gets the benefit of the doubt.

You do not call RefreshAsync on the hot path. Run it on a timer — every few minutes, or on app resume — and let Validate() serve every actual gate decision from the cached lease in between.

Reading the cached validation and its signed timestamp

When Validate() is answering from cache rather than from a fresh confirmation, LicenseInfo tells you so, and it carries the signed timestamp that bounds the grace window. Every field here is covered by the service's signature over the lease, so none of it can be edited on disk without breaking verification.

LicenseInfo info = License.Validate(); if (info.ValidationSource == ValidationSource.Cache) { DateTimeOffset confirmedAt = info.Lease.ConfirmedAtUtc; // signed by the service TimeSpan remaining = info.GraceRemaining; // how long cached operation is still allowed _log.LogInformation( "Running on cached license: last confirmed {ConfirmedAt:u}, grace remaining {Remaining}.", confirmedAt, remaining); if (remaining < TimeSpan.FromHours(6)) ShowBanner($"License server unreachable. Reconnect within {remaining:hh\\:mm} to stay licensed."); } if (info.Edition == LicenseEdition.Community) { // Grace exhausted (or never licensed): the SDK has already fallen back to // Community Edition. Gate the paid entitlements, cleanly. DisablePaidFeatures(); } 

Note the fallback target: when grace is exhausted, a self-hosted deployment drops to Community Edition, not to a crash. Paid entitlements read as disabled through the same IsEnabled / GetLimit calls you already use, so failing closed is just your ordinary gating doing its job — no special "we locked you out" code path.

Clock considerations: why you cannot cheat grace by unplugging

The obvious attack on any offline grace window is to pull the network and wind the clock back so grace never elapses. Keyright closes this on two fronts.

Grace is measured from a signed instant, not a local one. The countdown runs from Lease.ConfirmedAtUtc, which the service signed when it last answered. A user can change their machine clock, but they cannot move a timestamp that is inside a signed payload without invalidating the signature — and an invalid signature fails closed immediately.

A backward clock is treated as tamper. Alongside the signed timestamp the SDK persists a monotonic marker and the latest signed server time it has seen. If the wall clock later reads earlier than that high-water mark — the signature of a deliberate rollback — the SDK stops trusting the local clock and fails closed rather than granting open-ended grace:

if (info.ClockStatus == ClockStatus.WentBackwards) { // Wall clock is earlier than the last signed server time we recorded. // Treat as tamper: do not extend grace. GateApplication("System clock rolled back; reconnect to re-validate."); } 

MaxOfflineDuration is the final backstop. Even if every clock check passed, no cached lease is honoured past ConfirmedAtUtc + MaxOfflineDuration. Unplugging the cable buys exactly GracePeriod, and across repeated short outages it buys at most MaxOfflineDuration in total — never forever.

Telemetry while running on cached validation

Running on cache is a degraded state, and you want to know it is happening before a customer tells you. Two things are worth emitting. First, a structured log line each time a gate decision is served from cache rather than a fresh confirmation — exactly the ValidationSource == Cache branch above — carrying confirmedAt and remaining so you can see how deep into the window a fleet has drifted. Second, a metric or health signal you can alert on: if a meaningful share of instances are on cached validation at once, that is your issuing service having a bad day, not a scatter of individual customers going offline. Keep the key itself out of those logs (redact to a prefix) the same way you would anywhere else.

Failing closed, cleanly

When grace finally runs out — MaxOfflineDuration reached, clock rollback detected, or a definitive revocation came back the instant the service was reachable — the end state is the same and it should be undramatic. Validate() returns LicenseEdition.Community, paid entitlements read disabled, and your existing gating narrows the app to its free surface. The customer sees a clear, actionable message ("reconnect to re-validate") rather than a stack trace, and the moment the service is reachable again a single RefreshAsync confirms the license and restores full function with no reinstall and no re-entry of a key.

That is the whole shape of it: an outage you caused should cost your customer nothing, a cancellation should cost them their paid features promptly, and the difference between the two is a signed timestamp and a bounded window — never a dropped connection mistaken for a verdict.