Skip to content
← All posts
· Delta1 Labs Deep diveKeyrightLicensing

Fail-closed licensing in .NET: what your app does when it cannot check

Every licensing integration eventually faces the moment it cannot reach the server or read a key. Fail open and piracy is trivial; fail closed naively and you lock out paying customers during an outage. How Keyright uses leases, a grace window, and fail-closed-after-grace enforcement to get both right.

Every licensing integration has a happy path: the app starts, validates a license, unlocks the right features, and runs. That path is easy. The entire value of a licensing system lives in the unhappy path — the moment the app cannot validate. The network is down. The server is mid-deploy. The clock is wrong. The license file was deleted. A disk is read-only. What your application does in that instant is the whole design, and most integrations get it wrong in one of two equally bad directions.

Fail open — “if I can’t check, assume it’s fine” — makes piracy trivial: block the validation endpoint with a firewall rule and the product runs unlicensed forever. Fail closed naively — “if I can’t check, shut down” — turns a thirty-second server blip, or a laptop on a plane, into a hard outage for a paying customer. Neither is acceptable. The right answer is fail closed, but not immediately, and getting the “not immediately” part right is what separates a licensing system you can ship from one that generates support tickets.

Leases turn one check into a window of trust

The mechanism underneath is a lease. When Keyright validates a license — online or from a signed offline file — it does not return a boolean. It returns a lease: a validation result that is good until an explicit expiry, carried with a signature so it cannot be forged or extended by editing a file.

public sealed record Lease(
    LicenseStatus Status,       // Active, Grace, Expired, Revoked
    Entitlements Entitlements,  // what this license unlocks
    DateTimeOffset IssuedAt,
    DateTimeOffset ValidUntil,  // hard stop for THIS lease
    DateTimeOffset GraceUntil); // keep running, degraded-trust, until here

The client caches the lease and, crucially, does its own enforcement against the clock without calling the server again until the lease is due for renewal. A typical desktop product revalidates daily but holds a lease valid for, say, seven days. That gap is deliberate: it is the buffer that absorbs outages.

async Task<Lease> EnsureLeaseAsync(Lease? cached, CancellationToken ct)
{
    var now = _clock.UtcNow;

    // Still comfortably inside the current lease — don't even phone home.
    if (cached is { } l && now < l.ValidUntil - _renewAhead)
        return l;

    try
    {
        // Renew. On success we get a fresh ValidUntil pushed into the future.
        return await _client.RenewAsync(ct);
    }
    catch (Exception ex) when (ex is HttpRequestException or TimeoutException)
    {
        // Could not reach the server. This is the decision point.
        return Degrade(cached, now);
    }
}

The catch is where the philosophy lives. We did not reach the server — but we are not blind, because we still hold the last signed lease. The question is not “online or offline”; it is “how long since we last had proof, and is that still within tolerance?”

The grace window: fail closed, but give the world a chance to come back

Degrade implements the actual policy. A lease has two horizons: ValidUntil (when this proof-of-validation goes stale) and GraceUntil (the hard wall). Between them the app keeps running, but it knows its trust is degrading and it says so.

Lease Degrade(Lease? cached, DateTimeOffset now)
{
    if (cached is null)
        // Never validated on this machine, and can't now. Nothing to trust.
        throw new LicensingException(Reason.NoLease);

    if (now < cached.ValidUntil)
        return cached;                              // not even stale yet

    if (now < cached.GraceUntil)
        return cached with { Status = LicenseStatus.Grace };   // stale, still allowed

    // Past the grace wall with no successful renewal. Now we fail closed.
    return cached with { Status = LicenseStatus.Expired };
}
What the app does as proof-of-validation agesActive — full features, silentGrace — runs, warns, retriesFail closedlast OK validationValidUntilGraceUntilevery renewal that succeeds slides all three markers to the right

Three things make this humane rather than hostile:

  1. The grace window is tied to real time, not app launches. A machine that stays offline for a weekend is fine on Monday. The clock advancing does not, by itself, end the lease — only crossing GraceUntil does, and every successful renewal pushes it forward.
  2. Grace is loud, Active is silent. In Active, the user sees nothing — enforcement that nags a paying customer is a bug. In Grace, the app surfaces a quiet, dismissable “couldn’t reach licensing, retrying” notice and keeps trying in the background. The customer finds out before the wall, not at it.
  3. Expiry degrades, it rarely kills. LicenseStatus.Expired should gate the licensed capabilities, not Environment.Exit. A tool that deletes the user’s unsaved work because a server was briefly unreachable has confused enforcement with sabotage.

Clock tampering is the obvious attack — handle it explicitly

A reader three steps ahead has spotted the hole: this is all driven by _clock.UtcNow, so can’t an attacker just set their clock back to stay in Active forever? Yes — if you trust the system clock blindly. Keyright does not. The lease records the highest timestamp the client has ever seen (from signed server responses and its own monotonic readings), and refuses to believe the wall clock has moved backward past it.

DateTimeOffset MonotonicNow()
{
    var wall = DateTimeOffset.UtcNow;
    // Anchor: (serverTimeAtLastSync, Stopwatch elapsed since that sync).
    var anchored = _anchorServerTime + _sinceSync.Elapsed;

    // Trust whichever is LATER — rolling the wall clock back cannot rewind us.
    return wall > anchored ? wall : anchored;
}

Rolling the clock back now does nothing: the monotonic anchor keeps advancing, the lease ages normally, and the attacker lands in Grace and then fail-closed on schedule. Rolling it forward only ends their own lease faster. This is the same clock-tamper defense used for offline validation — enforcement and offline both lean on it.

Where self-hosting changes the calculus

If you run Keyright self-hosted, you own the issuing service, and “the server was unreachable” now includes “my own instance was down.” The grace window is what buys you room to restart, migrate, or move the database without a single customer noticing — their leases are valid for days, not seconds. Set the lease lifetime and grace window to comfortably exceed your worst realistic maintenance window. A product that requires its licensing server to have higher uptime than the product itself has inverted the dependency; the lease is the inversion-breaker.

The default worth internalizing: fail closed, after a grace window longer than any outage you can plausibly cause, with a clock you don’t blindly trust. Fail open and you are not really licensed. Fail closed instantly and you have built a denial-of-service against your own customers. The lease — a signed, time-bounded, forward-only window of trust — is how you get to be strict without being brittle.

Keyright ships lease-based, fail-closed-after-grace enforcement in every SDK. See the Keyright getting-started guide to wire it into your product.

Try Nebula.NET

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