DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Originally published at delta1labs.com

One license, many clocks: per-entitlement expiry with Keyright

Most licensing stacks give a license exactly one clock: an expiresAt for the whole key. That models the simplest contract — an annual subscription that either renews or lapses — and nothing else. Real commercial agreements are messier. A customer is on an annual base plan, trialing a premium add-on for 30 days, and also carrying a promotional feature you threw in until the end of the quarter. Three capabilities, three different end dates, one customer.

With a single-clock license you have two bad options. Either you issue several licenses — one per end date — and now your app has to juggle a bag of keys and reconcile them, or you pick the latest date for the whole license and silently over-grant the trial and the promo until then. Neither is honest. The fix is to stop thinking of expiry as a property of the license and start treating it as a property of each entitlement.

An entitlement can carry its own clock

If you have read Entitlements and feature flags, driven by the license, you know an entitlement is a named capability — a boolean flag like export, or a numeric limit like max-projects — baked into the signed license from a tier template. Per-entitlement expiry adds one field: each entitlement may specify when it stops applying.

The license payload stays a single signed object; the entitlement block just gets richer:

{
  "sub": "acct_8f21c",
  "tier": "pro",
  "iat": "2026-10-12T00:00:00Z",
  "exp": "2027-10-12T00:00:00Z",
  "entitlements": {
    "export":        { "value": true, "exp": "2027-10-12T00:00:00Z" },
    "sso":           { "value": true },
    "premium-trial": { "value": true, "exp": "2026-11-11T00:00:00Z" },
    "promo-reports": { "value": true, "exp": "2026-12-31T23:59:59Z" },
    "max-projects":  { "value": 25,   "exp": "2027-10-12T00:00:00Z" }
  }
}
Enter fullscreen mode Exit fullscreen mode

Three clocks are now visible. The top-level exp is the license's own lifetime — the hard outer boundary. premium-trial dies on 11 November, promo-reports on New Year's Eve, and sso has no exp at all, which means it lives for exactly as long as the license does. Each entitlement is evaluated against its own date, bounded by the license's.

Reading a per-feature clock in your app

The SDK surface barely changes, which is the point — IsEnabled and GetLimit already return the effective answer, and "has this entitlement's own clock run out?" is simply folded into that answer. A lapsed entitlement reports as disabled:

var client = KeyrightClient.Initialize(options);
await client.ActivateAsync(licenseKey);   // once; the lease is cached and verified

// premium-trial is true in the payload, but if its exp has passed,
// IsEnabled returns false — no special-casing at the call site.
if (client.IsEnabled("premium-trial"))
    ShowPremiumPanel();

// A numeric limit whose clock has run out falls back to your conservative value.
int maxProjects = client.GetLimit("max-projects", fallback: 1);
Enter fullscreen mode Exit fullscreen mode

Because the whole decision is local — compare the entitlement's exp to the current time, inside a payload whose signature you have already verified against the embedded public key — there is no network round-trip and nothing new to fail. The evaluation IsEnabled performs is roughly:

internal bool IsEnabled(string key)
{
    if (!_entitlements.TryGetValue(key, out var e)) return false;   // unknown → closed
    if (e.Value is not true) return false;                          // flag is off
    if (e.Expiry is DateTimeOffset exp && _clock.UtcNow >= exp)
        return false;                                               // this feature's clock ran out
    return true;
}
Enter fullscreen mode Exit fullscreen mode

Everything your existing code already relied on still holds: unknown flags fail closed, the signature is verified offline, and tampering with a timestamp invalidates the whole license. The only addition is the one _clock.UtcNow >= exp comparison, scoped to a single entitlement.

When you need the date itself — to render "Your premium trial ends in 6 days" — ask for it directly rather than reaching into the payload:

DateTimeOffset? ends = client.ExpiresAt("premium-trial");
if (ends is { } d && d - DateTimeOffset.UtcNow < TimeSpan.FromDays(7))
    ShowTrialEndingBanner(d);
Enter fullscreen mode Exit fullscreen mode

ExpiresAt returns null for an entitlement with no clock (it lives as long as the license) and the effective date otherwise — never a date past the license's own exp, because the license is still the outer bound.

Enforce at the capability boundary

The rule from entitlements in general applies unchanged: hiding a button is UX, not enforcement. Put the check where the capability actually lives, and let the expired-trial case fall out of the same IsEnabled call:

public Report BuildPromoReport(ReportOptions options)
{
    if (!_license.IsEnabled("promo-reports"))
        throw new FeatureNotLicensedException("promo-reports");

    return _renderer.Render(options);
}
Enter fullscreen mode Exit fullscreen mode

On 30 December this runs. On 1 January the same code throws — not because you shipped anything, and not because the license became invalid, but because that one entitlement's clock ran out. The customer keeps export, sso, and everything else on the license until the license's own exp.

Grace, skew, and the trustworthy clock

Per-feature expiry inherits the same two hazards every time-based check has, and you handle them the same way.

Clock skew and rollback. An offline check is only as honest as the device clock. A user who sets their clock back a month resurrects a dead trial — unless you detect it. Keyright's clock-tamper detection (covered in License clock tampering and time rollback) persists a monotonic high-water mark of the last time the app saw, and refuses to accept a wall clock that has moved backwards past it. Per-entitlement expiry sits directly on top of that: the _clock.UtcNow in the evaluation above is the validated time, not raw DateTimeOffset.UtcNow.

Grace, decided per capability. When the license enters its grace window you usually keep the app running read-only for a few days (see License server downtime and grace windows). A single expiring entitlement is gentler: the default is a hard off at its exp, because the rest of the app is unaffected and the customer loses only the add-on. If you want a soft landing for a specific feature — say, a few days where a trial degrades to read-only before it disappears — encode that as policy where you read the flag, not as a second expiry date:

public bool CanEditWithPremium()
{
    if (_license.IsEnabled("premium-trial")) return true;

    // Optional soft landing: 3 read-only days after the trial clock lapses.
    var ended = _license.ExpiredAt("premium-trial");   // when it lapsed, or null
    return ended is { } t && DateTimeOffset.UtcNow - t < TimeSpan.FromDays(3)
        ? false   // view allowed elsewhere; editing is off
        : false;
}
Enter fullscreen mode Exit fullscreen mode

Keeping grace in your code rather than in the payload means the license says one honest thing — this feature ends on this date — and your product decides how gracefully to let go of it.

Why this beats juggling keys

Per-entitlement expiry collapses a whole category of operational pain:

  • One key per customer, always. A trial add-on, a promo, and a base plan with three different end dates still ship as a single license. Your activation flow, your storage, and your support team deal with one key.
  • Promos and trials expire themselves. You issue the license once with the promo's end date on that one entitlement. Nobody has to remember to reissue or revoke when the quarter turns — the clock is already in the signed payload.
  • Upsell without a cliff. When the premium trial lapses, the app drops to the base experience the customer paid for, not to a lock screen. That is the moment to show an upgrade prompt, and ExpiresAt gives you the exact date to build it around.
  • It stays offline and tamper-evident. Every clock rides inside the same signed template, verified locally. No new endpoint, no new round-trip, and any edit to any date breaks the signature.

A license was never really one contract with one end date — that was just the easiest thing to model. Give each entitlement its own clock and the license finally matches the deal you actually sold. See the Keyright entitlements guide for the capability model this builds on, and license clock tampering for the validated clock it depends on.

Top comments (0)