Skip to content
← All posts
· Delta1 Labs Licensing.NETGuide

Turning on license enforcement without locking out your customers: audit mode first

The scariest deploy in any licensing project is the one that flips enforcement from off to on. Up to that point the gate has never said no to anyone; the moment it does, every mis-provisioned key, every clock that is a day off, every customer who activated behind a proxy you did not anticipate becomes a support ticket — all at once, in production. This post shows how to avoid the big-bang cutover with audit mode: a gate that evaluates the license exactly as it will in enforce mode, records the decision it would have made, and then allows the call anyway. You run it across your installed base, read the would-have-blocked population, fix the data, and only then flip to enforce — per cohort, driven by a mode the signed license carries so no client can turn it off. With a decision record, an enforcement-mode enum the payload signs, a staged rollout, and the metrics that tell you it is safe to block.

The scariest deploy in a licensing project is not the one that adds the gate. It is the one that turns it on.

Adding a license check to a .NET app that already has users is mostly mechanical: you verify a signed payload, read the claims, and decide. You can test every branch of that logic. What you cannot test, until it is live, is your own installed base — the keys real customers are actually holding, the clocks their machines are actually running, the network paths their activations actually take. The day you flip enforcement from “log the decision” to “act on the decision,” all of that reality arrives at once, and every discrepancy you did not anticipate becomes a locked-out customer and a support ticket in the same five minutes.

Audit mode is how you decouple those two events. You ship the gate computing the exact decision it will eventually enforce, but acting on none of the denies — recording them instead. Then you watch, fix, and flip, one cohort at a time, from a mode the license itself carries.

signed license payloadsub: cust_8831edition: proexp: 2027-10-12enforcementMode: auditevaluate (same in both modes)decision = Denyreason: SeatCapExceededaudit moderecord would-blockcall proceedsenforce mode (later)same Denycall blockedone branch differs: whether a Deny stops the call

Separate the decision from the action

The first design mistake is to write the gate as a function that returns a boolean and throws when the boolean is false. That couples two things that must be separable: what the license says and what you do about it. Audit mode only exists if those are distinct.

So the gate produces a value, not a side effect. A decision is an outcome plus a reason plus enough context to explain it in a log later:

public enum LicenseOutcome { Allow, Deny, Indeterminate }

public sealed record LicenseDecision
{
    public required LicenseOutcome Outcome { get; init; }
    public string? Reason { get; init; }              // "SeatCapExceeded", "Expired", "FeatureNotLicensed"
    public string? Feature { get; init; }             // what was being gated
    public DateTimeOffset EvaluatedAt { get; init; }

    public static LicenseDecision Allow() =>
        new() { Outcome = LicenseOutcome.Allow, EvaluatedAt = DateTimeOffset.UtcNow };

    public static LicenseDecision Deny(string reason, string? feature = null) =>
        new() { Outcome = LicenseOutcome.Deny, Reason = reason, Feature = feature,
                EvaluatedAt = DateTimeOffset.UtcNow };

    public static LicenseDecision Indeterminate(string reason) =>
        new() { Outcome = LicenseOutcome.Indeterminate, Reason = reason,
                EvaluatedAt = DateTimeOffset.UtcNow };
}

Indeterminate is deliberately a first-class outcome, not a Deny. It is the “I could not decide” case — the server was unreachable, the cached license had not refreshed, the clock could not be trusted. Collapsing it into Deny is the single most common reason a licensing rollout locks out customers who were never actually unlicensed, and keeping it separate is what makes the audit metric meaningful later.

The evaluation that produces the decision is the real enforcement logic, and it is identical in both modes:

public LicenseDecision Evaluate(string feature, int seatsInUse)
{
    if (!_license.IsPresent)
        return LicenseDecision.Indeterminate("NoLicenseCached");

    if (_license.Expiry < _clock.TrustedUtcNow())
        return LicenseDecision.Deny("Expired", feature);

    if (!_license.Features.Contains(feature))
        return LicenseDecision.Deny("FeatureNotLicensed", feature);

    if (_license.SeatCap is int cap && seatsInUse > cap)
        return LicenseDecision.Deny("SeatCapExceeded", feature);

    return LicenseDecision.Allow();
}

Nothing here knows whether you intend to act on a Deny. That knowledge lives one level up, in the mode.

The mode belongs in the license, not in config

Audit mode is only safe if the customer cannot reach it. If you read the mode from appsettings.json, an environment variable, or any unsigned file next to the binary, you have not built a rollout tool — you have built an off switch, and the first person to find it never gets blocked.

So the mode is a claim. It rides inside the signed payload alongside the expiry and the edition, which means the client reads it out of bytes whose signature it already verifies. Flipping enforce to audit breaks the signature exactly the way editing the expiry would:

public sealed record LicensePayload
{
    public required string Sub { get; init; }
    public required string Edition { get; init; }
    public required DateTimeOffset Exp { get; init; }
    public int? SeatCap { get; init; }
    public IReadOnlyList<string> Features { get; init; } = [];

    // Rollout control, signed like everything else. Default to enforce:
    // a payload minted before this claim existed must be treated as enforcing,
    // never as audit, or an old key becomes an accidental bypass.
    public EnforcementMode EnforcementMode { get; init; } = EnforcementMode.Enforce;
}

public enum EnforcementMode { Enforce, Audit }

The defaulting direction matters. An older payload that predates the enforcementMode claim deserializes with the field absent, and the default must be Enforce. If you default the other way, every key you ever issued before adding the claim silently becomes un-enforceable — the opposite of what you want. Fail safe means defaulting to the strict value.

With the mode in hand, the gate’s top layer is the one branch the whole design turns on:

public bool Gate(string feature, int seatsInUse)
{
    var decision = Evaluate(feature, seatsInUse);

    // Indeterminate is a separate policy decision (your grace / fail-open rule),
    // handled before mode so the audit metric only ever counts confident denies.
    if (decision.Outcome == LicenseOutcome.Indeterminate)
        return _gracePolicy.AllowOnIndeterminate(decision);

    if (decision.Outcome == LicenseOutcome.Allow)
        return true;

    // decision.Outcome == Deny
    _auditSink.Record(decision, _license.Sub, _license.EnforcementMode);

    return _license.EnforcementMode == EnforcementMode.Audit;  // audit: proceed anyway
}

Read that last line carefully: in audit mode a confident Deny is recorded and then the method returns true, so the call proceeds. In enforce mode the same Deny returns false. The evaluation above it did not change. That is the property you want — the audit and enforce behaviours are provably the same decision, differing only in whether you act on it — because it is what lets you trust that the population you measured in audit is exactly the population you will block in enforce.

What the audit sink records

The record is the whole point of the exercise, so it has to carry enough to act on without carrying anything you should not collect. You want, per would-have-blocked event: the subject the license was issued to, the reason, the feature, the mode in effect, and a timestamp. You do not want the raw license bytes, personal data, or anything that identifies a human rather than a license.

public sealed record AuditEvent
{
    public required string Sub { get; init; }          // which license, not which person
    public required string Reason { get; init; }        // SeatCapExceeded, Expired, ...
    public string? Feature { get; init; }
    public required EnforcementMode Mode { get; init; }
    public required DateTimeOffset At { get; init; }
    public required string AppVersion { get; init; }    // so you can see which build reports
}

For a self-hosted deployment this batches to the issuing service you already call for activation; for the SaaS product it is the same channel. Either way the shape is the same, and the aggregate you build from it is a table you will read every morning during rollout: reason, count of distinct subjects, trend over the last few refresh cycles. SeatCapExceeded climbing is a sales conversation; Expired climbing is a renewal-ops problem; FeatureNotLicensed on a feature you thought everyone had is a provisioning bug you get to fix before it ever denies anyone.

Flip per cohort, by minting, not deploying

Because the mode is a claim, flipping a customer from audit to enforce is an act of issuing, not deploying. You do not ship a new build; you mint their next license (or re-issue the current one) with enforcementMode: enforce, and the client picks it up on its next refresh. That gives you a rollout knob with per-subject resolution and no client-side coordination:

// Issuing side: decide the mode as licenses are minted or refreshed.
EnforcementMode ModeFor(string sub) =>
    _rollout.IsEnforced(sub) ? EnforcementMode.Enforce : EnforcementMode.Audit;

The staged rollout then reads straight off the audit data. Start with the cohort whose audit record is cleanest — internal tenants, then the customers with zero would-have-blocked events across a full refresh cycle, because those are the ones for whom enforce changes nothing. Expand outward as each reason in the audit table drops to a floor you can attribute. The customers still generating denies stay in audit until you have fixed the data or had the conversation; they are never surprised, because enforcement for them simply has not been minted yet.

would-have-blocked subjects / dayhigh0cycle 1cycle 2cycle 3floor: every remaining deny attributable← flip clean cohort to enforce here, not earlier

Indeterminate is not a deny, and audit is not fail-open

The two mistakes that undo all of this are the same mistake wearing different clothes.

The first is folding Indeterminate into Deny. If a server timeout or an empty cache produces a Deny, your audit table fills with events that have nothing to do with licensing, the would-have-blocked rate never settles because it is tracking your network instead of your licenses, and when you finally flip to enforce you lock out everyone who happened to be offline. Keep Indeterminate separate and route it through whatever grace or fail-open policy you already have — the audit metric must count only the denies you are sure about, because those are the only ones enforce will act on.

The second is confusing audit mode with fail-open. Fail-open answers “what do we do when we cannot decide”; audit answers “what do we do with a decision we have made but choose not to act on yet.” A gate can be in audit mode and still fail closed on indeterminate cases — the two switches are independent, and conflating them means you can neither reason about your rollout nor explain, after the fact, why a particular call was allowed.

Get those two right and the final deploy — the one that scared you — stops being a deploy at all. It is a line in the issuing service that mints the next license with enforcementMode: enforce for a cohort whose would-have-blocked rate you have already watched fall to a floor you understand. The gate code does not change. You already know, to the subject, who it will affect, because you spent two refresh cycles watching it affect no one.

Try Nebula.NET

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