DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Edited on Originally published at delta1labs.com

Verifying a license key is not free: caching the signature check without caching a stale answer

A Keyright license is a signed document. The issuing service holds an RSA private key, signs a canonical payload — the edition, the entitlements, the node-lock, the expiry — and every copy of your application embeds the matching public key and verifies that signature offline. That verification is the whole foundation of the scheme: it is what makes a license you can read on the client impossible to forge. It is also not free, and the single most common performance mistake with any signed-license SDK is to pay for it far more often than the facts require.

Here is the mistake in its purest form — a license check wired straight into an ASP.NET Core authorization handler:

public async Task HandleRequirementAsync(
    AuthorizationHandlerContext context, EntitlementRequirement requirement)
{
    // Called on every single request to a gated endpoint.
    LicenseInfo info = _client.Validate();          // ← RSA verify + parse, every time
    if (info.IsEnabled(requirement.Entitlement))
        context.Succeed(requirement);
}
Enter fullscreen mode Exit fullscreen mode

Validate() is pure, offline, local computation — there is no network call here, the lease was cached at activation. But "local" is not the same as "cheap enough to do thousands of times a second." Every call re-runs the RSA signature verification over the license bytes, re-decodes the base64 payload, re-parses it, and rebuilds the entitlement set, only to answer a question whose inputs have not changed since the process started.

What the call actually costs

It helps to be concrete about where the time goes. A Validate() call does, in order: base64-decode the license string, verify the RSA-2048 signature over the canonical payload, parse the payload (UTF-8 into fields), build the entitlement dictionary, then evaluate the license against the current time. Only the last step depends on when you call it. Everything before it depends only on the license bytes and your public key.

None of these numbers is alarming on its own — tens of microseconds for the verify, a few short-lived allocations for the parse. The damage is in the repetition. Put this on a per-request path at a thousand requests a second and you are burning a core's worth of time, and generating a steady stream of garbage, to re-prove a signature that was already valid. The fix is not a faster crypto library. It is to stop asking the expensive question when you already know the answer.

Verify once, evaluate every time

The key insight is that the two halves have completely different cache lifetimes. The signature verification and parse are immutable for as long as the process holds the same license bytes — verifying the same bytes against the same public key a second time cannot produce a different result. The evaluation at the current instant is the opposite: it is exactly the part that is supposed to change, because an entitlement can carry its own expiry and the validated clock moves forward.

So split them. Verify and parse once, hold the resulting LicenseInfo snapshot, and on every call do only the cheap, time-dependent evaluation against that snapshot. LicenseInfo is an immutable value — the SDK produces it and never mutates it — so it is safe to share across threads without a lock.

public sealed class LicenseSnapshot
{
    private readonly KeyrightClient _client;
    private volatile LicenseInfo _verified;   // signature-verified, parsed once
    private long _verifiedAtTicks;
    private readonly long _refreshTicks = TimeSpan.FromSeconds(2).Ticks;

    public LicenseSnapshot(KeyrightClient client)
    {
        _client = client;
        _verified = client.Validate();                 // pay the RSA cost once
        _verifiedAtTicks = DateTime.UtcNow.Ticks;
    }

    private LicenseInfo Current()
    {
        long now = DateTime.UtcNow.Ticks;
        if (now - Volatile.Read(ref _verifiedAtTicks) > _refreshTicks)
        {
            _verified = _client.Validate();            // re-verify on a short interval
            Volatile.Write(ref _verifiedAtTicks, now);
        }
        return _verified;
    }

    // Hot path: a field read plus a time comparison. No crypto, no allocation.
    public bool IsEnabled(string entitlement) => Current().IsEnabled(entitlement);
}
Enter fullscreen mode Exit fullscreen mode

Two things make this correct rather than merely fast. First, IsEnabled on the snapshot is the cheap half — for a Keyright entitlement that carries a per-entitlement expiry, IsEnabled compares that expiry against the validated clock at call time, so a feature that lapses at noon reads as disabled at 12:00:01 even though the snapshot was verified at breakfast. The expensive signature check is cached; the time-sensitive decision is not. Second, the refresh interval is short — seconds, not minutes. It exists only to pick up a changed lease (a renewal that widened the plan, a revocation that collapsed it to Free), not to ration CPU, because the CPU cost was already removed by caching the snapshot between refreshes.

The trap: caching the answer instead of the verification

The failure mode worth naming out loud is the one that looks like an optimization and is actually a correctness bug. It is tempting to cache the boolean — "is export enabled?" — because that is the value your call site wants:

// WRONG: caches the time-dependent answer.
private readonly ConcurrentDictionary<string, bool> _answers = new();
public bool IsEnabled(string ent) =>
    _answers.GetOrAdd(ent, e => _client.Validate().IsEnabled(e));
Enter fullscreen mode Exit fullscreen mode

This is fast and wrong. The moment export expires, this dictionary keeps returning true for the life of the process, because nothing re-evaluates the expiry. You have cached the one thing that was supposed to change and left the one thing that never changes — the signature verification — running on every cache miss. The correct split caches the snapshot (the invariant) and re-runs the evaluation (the variable). Hold the parsed license; never hold the verdict.

When you still want to short-circuit

There is a legitimate micro-optimization on top of this, and it is not caching the verdict — it is skipping the refresh check when the clock has obviously not moved enough to matter. The Current() method above already does the cheapest possible gate: one DateTime.UtcNow read and an integer compare. If profiling says even that is too much on an extreme hot path, pass the snapshot itself to the caller and let a tight inner loop read entitlements straight off it, refreshing only at the boundaries of a batch:

// Batch boundary — refresh once, then evaluate N times with zero overhead.
LicenseInfo info = _snapshot.Current();
foreach (var job in batch)
    if (info.IsEnabled("bulk-process"))
        Process(job);
Enter fullscreen mode Exit fullscreen mode

This is safe precisely because the batch is short relative to the refresh interval, so the clock cannot meaningfully advance across it. For anything longer-lived — a request handler, a background service tick — go back through Current() so a newly lapsed entitlement is seen.

The rule

A signed license check is two jobs wearing one method name. Verifying the signature proves the license is authentic, costs real microseconds, and has an answer that does not change while the process holds the license — so verify once and hold the snapshot. Evaluating the license tells you what is true right now, costs nanoseconds, and is exactly the part that is supposed to change — so never cache it. Get the split right and you take public-key cryptography off your hot path without ever returning an answer that is stale, which is the whole point: fast and honest, not one at the expense of the other.

Keyright's SDK gives you the raw material for this — an offline Validate() that returns an immutable LicenseInfo, and a cached lease so you are never back on the network. See entitlements and feature flags for the capability model the snapshot carries, per-entitlement expiry for why the evaluation must stay per-call, and fail-closed gating for what the snapshot resolves to when a license is missing or revoked.

Top comments (0)