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

Versioning your license payload: adding claims without breaking the clients already in the field

A signed license is a set of claims frozen into bytes, and the moment you ship a client that reads it, that byte layout becomes a contract you cannot unilaterally change. Then product asks for a new claim — a seat cap, a region lock, a feature list — and you discover that half your installed base is running a parser that has never seen it. This post walks through how Keyright-style license payloads evolve safely: a schema version the signature covers, open-world parsing that keeps unknown claims verifiable, a critical-claims list so an old client refuses what it cannot enforce rather than silently ignoring it, schema negotiation at activation so the issuing side never emits what a client cannot read, and the golden-file tests that stop a format change from reaching production unnoticed.

A license key is a set of claims that the issuing service signs and the client verifies: who it is for, which product, which edition, when it expires, what machine it is bound to. The first version is easy. You define a record, serialize it, sign the bytes, ship a client that checks the signature and reads the fields, and it works.

The trouble starts on the day you need a new claim. Product wants a seat cap. Sales wants a region restriction. Engineering wants a feature list so one key can unlock a subset of modules. Each of those is a change to the payload — and the payload is already being parsed by every copy of your software in the field, including the ones that will not update for a year. Some of those installations will read the new payload and quietly ignore the field they do not understand; whether that is correct depends entirely on what the field means. Ignore a display name and nothing happens. Ignore a seat cap and you have just given away unlimited seats to everyone who has not upgraded.

This post is about doing that evolution deliberately: a version the signature covers, parsing that tolerates what it does not understand without weakening verification, a way to mark the claims an old client is not allowed to ignore, negotiation so the server never emits a payload a client cannot read, and tests that make the whole thing hold across releases.

signed payload (schema 2)schema: 2sub: cust_8831product: acme-appedition: proexp: 2027-10-11displayName: "Acme Corp"seatCap: 25critical: ["seatCap"]sig: RSA-PSS over canonical bytesschema-2 clientverifies signature over raw bytesreads displayName, enforces seatCap = 25every critical claim understood → license validschema-1 client (older build)verifies signature over raw bytes — still passesdisplayName unknown → kept, ignored (harmless)seatCap unknown but listed in critical → refuse, "update required"

The payload is a contract the signature freezes

Start with the shape you already have. A first-generation Keyright-style payload might be this record, serialized to canonical JSON and signed as a detached signature over those bytes:

public sealed record LicensePayload
{
    public required string Sub { get; init; }        // customer id
    public required string Product { get; init; }    // product slug the key is issued for
    public required string Edition { get; init; }    // "pro", "team", ...
    public required DateTimeOffset Exp { get; init; }
    public string? MachineId { get; init; }          // node-lock, optional
}

The important thing about this record is not the fields; it is that the encoding of those fields is now fixed. The signature covers the exact bytes the issuing service produced, so verification on the client depends on reproducing — or, better, never altering — those bytes. We covered why field order and encoding matter in the post on canonical signing; here the consequence is that the format is a contract between two parties who upgrade on completely different schedules. The issuing service upgrades when you deploy it. The client upgrades when the customer feels like it.

Every change to the payload therefore has to be judged by one question: what does the oldest client I still support do when it sees this? There are only three honest answers — it reads it correctly, it ignores it harmlessly, or it ignores it dangerously — and the whole design below exists to make sure the third never happens silently.

Rule one: the schema version is a claim, and it is signed

Put a schema integer in the payload from day one, even if it is 1 and nothing reads it. Because it is inside the signed bytes, nobody can downgrade a payload to a version with weaker semantics without breaking the signature. Because it is a plain integer, the client can make decisions on it before it understands anything else.

public sealed record LicensePayload
{
    public required int Schema { get; init; }          // 1, 2, 3 ... — signed like every other claim
    public required string Sub { get; init; }
    public required string Product { get; init; }
    public required string Edition { get; init; }
    public required DateTimeOffset Exp { get; init; }
    public string? MachineId { get; init; }

    // Schema 2 additions:
    public string? DisplayName { get; init; }          // informational — safe to ignore
    public int? SeatCap { get; init; }                 // restriction — NOT safe to ignore
    public IReadOnlyList<string> Critical { get; init; } = [];   // names of claims that must be understood

    // Anything we do not have a property for lands here and is preserved.
    [JsonExtensionData]
    public Dictionary<string, JsonElement>? Unknown { get; init; }
}

The Schema number is not “the version of the client that can read this”. It is “the version of the vocabulary this payload was written in”. A schema-1 client can perfectly well read a schema-2 payload as long as the schema-2 additions are things it is allowed to ignore — and that is what the next two rules decide.

Rule two: verify the bytes, then parse open-world

The parser must tolerate claims it does not know. In System.Text.Json that is the [JsonExtensionData] dictionary above: every property the record does not declare is collected rather than rejected. This is open-world reading, and it is what lets an old client accept a payload that carries new informational claims.

The trap is to combine open-world reading with closed-world verification. If the client deserializes the payload into its record, then re-serializes the record and verifies the signature over that, an old client that dropped (or reordered) an unknown field produces different bytes and the signature fails on a perfectly valid license. The order of operations has to be: verify the signature over the raw bytes exactly as received, and only then hand those bytes to the parser.

public static LicensePayload VerifyAndRead(ReadOnlySpan<byte> payloadBytes,
                                           ReadOnlySpan<byte> signature,
                                           RSA issuerPublicKey)
{
    // 1. The signature covers the bytes on the wire. Verify before touching them.
    if (!issuerPublicKey.VerifyData(payloadBytes, signature,
                                    HashAlgorithmName.SHA256, RSASignaturePadding.Pss))
        throw new LicenseException(LicenseFailure.BadSignature);

    // 2. Only now parse — open-world, unknown claims retained in Unknown.
    var payload = JsonSerializer.Deserialize<LicensePayload>(payloadBytes, PayloadJson.Options)
                  ?? throw new LicenseException(LicenseFailure.Malformed);

    // 3. Schema gate: refuse only what we cannot possibly interpret.
    if (payload.Schema < 1 || payload.Schema > PayloadJson.MaxSchemaUnderstood + 1)
        throw new LicenseException(LicenseFailure.UnsupportedSchema, payload.Schema);

    return payload;
}

Note the gate in step 3. It does not say “refuse anything newer than me”. It says “refuse anything I cannot even begin to interpret” — more than one schema ahead, or a schema that never existed. One schema ahead is explicitly allowed through, because the critical-claims rule is about to handle it properly.

Rule three: critical claims — the old client must refuse what it cannot enforce

The X.509 certificate format solved this exact problem decades ago with the critical flag on extensions: an extension a verifier does not recognise can be ignored unless it is marked critical, in which case the certificate must be rejected. Licensing has the same two kinds of claim, and the same answer works.

An informational claim — a display name, a support contact, a purchase order reference — can be ignored by a client that has never heard of it. Nothing about enforcement changes.

A restrictive claim — a seat cap, a region lock, an API-rate ceiling, a “this key may only run versions released before this date” coverage bound — changes what the client is allowed to do. A client that ignores it does not merely show less; it over-grants. The old client must know that something it does not understand is restricting it and fail closed.

The Critical list makes that knowable without the old client understanding the claim itself. The issuing service lists the names of every restrictive claim it put in the payload; the client checks that it recognises every name in that list, and refuses the license — with a clear “update required” reason, not a generic failure — if any is unfamiliar.

static readonly HashSet<string> KnownClaims = new(StringComparer.Ordinal)
{
    // Every claim this build can *enforce*. Informational claims need not be listed —
    // they are never critical — but listing them is harmless.
    "sub", "product", "edition", "exp", "machineId",
    "seatCap",        // added in the schema-2 client
};

public static LicenseInfo Evaluate(LicensePayload p)
{
    foreach (var name in p.Critical)
    {
        if (!KnownClaims.Contains(name))
            return LicenseInfo.Free(LicenseFailure.UpdateRequired,
                                    $"License requires a claim this version cannot enforce: {name}");
    }

    // From here on, enforcement is normal: every claim we know, we apply.
    if (p.Exp < Clock.UtcNow) return LicenseInfo.Free(LicenseFailure.Expired);
    if (p.SeatCap is int cap && Seats.InUse() > cap) return LicenseInfo.Free(LicenseFailure.SeatCapExceeded);
    ...
    return LicenseInfo.Paid(p.Edition, p);
}

This is the same fail-closed posture that Keyright’s Validate() takes everywhere else — it never throws, it returns a Free edition with a reason — applied to the format itself. The old client does not pretend. It says “there is a restriction on this license that I am too old to honour,” which is exactly the message support wants to see in the log.

A detail worth getting right: Critical must itself be in the schema-1 vocabulary, or a schema-1 client cannot see it. That is why the list goes in on day one as an empty array. If you are retrofitting it onto a format that already shipped, you have one schema generation where the only safe additions are informational — and that is a strong argument for adding the list now, before you need it.

Rule four: never repurpose, never narrow

Two changes look like versioning and are actually breakage.

Repurposing a claim — keeping the name edition but changing the set of strings it can hold, or keeping exp but switching from seconds to milliseconds — breaks every old client in a way no version check catches, because the field parses fine and means something else. If a claim’s meaning changes, it is a new claim with a new name, and the old one is either kept populated for a deprecation period or removed in a major schema bump that old clients will refuse at the gate.

Narrowing a default — a claim that was optional-and-permissive in schema 1 becoming required-or-restrictive in schema 2 — is a restriction in disguise. If SeatCap absent meant “unlimited” in schema 1 and you want absent to mean “one seat” in schema 2, old clients will grant unlimited seats on every schema-2 key that omits the claim. Keep the permissive default and make the restriction explicit and critical instead.

The rule that covers both: within a schema generation, additions are the only change; the meaning of an existing claim and the behaviour on its absence are frozen. A major bump (schema 2 → 3) is where you may break those, and the gate in VerifyAndRead plus the critical list guarantees old clients refuse rather than misread.

Negotiation: the server should not emit what the client cannot read

Everything so far is defensive — the client protecting itself from a payload written in a newer vocabulary. The complement is for the issuing side to know what each client can read, which costs one integer on the activation request you already send.

public sealed record ActivationRequest
{
    public required string Product { get; init; }
    public required string LicenseKey { get; init; }
    public required string MachineId { get; init; }
    public required int MaxSchema { get; init; }      // the highest schema this client parses
    public required string ClientVersion { get; init; }
}

With that, the service has a real choice when a license needs a claim the client is too old for:

  • Express it in the older vocabulary when possible. A seat cap of 1 for a schema-1 client can be issued as a node-locked schema-1 key, which that client enforces natively.
  • Emit the newer schema and let the critical list do its job. The client fails closed with “update required” — correct, but it leaves the user with a non-working install until they upgrade.
  • Refuse activation with a precise reason — “this license requires client 3.2 or later” — so the message reaches the user at the moment they can act on it, not as a cryptic failure later.

Which one you pick is a product decision; what matters is that the server is deciding with information rather than guessing. The service also records MaxSchema per activation, which answers the question that otherwise paralyses every format change: how many of my customers are still on schema 1? When that number reaches zero you can retire the old emitter; until then you know exactly what you are breaking.

LicensePayload BuildPayload(LicenseRecord lic, ActivationRequest req)
{
    int target = Math.Min(req.MaxSchema, CurrentSchema);

    var p = new LicensePayload
    {
        Schema = target, Sub = lic.CustomerId, Product = lic.Product,
        Edition = lic.Edition, Exp = lic.ExpiresAt, MachineId = req.MachineId,
    };

    if (target >= 2)
    {
        p = p with { DisplayName = lic.CustomerDisplayName, SeatCap = lic.SeatCap,
                     Critical = lic.SeatCap is null ? [] : ["seatCap"] };
    }
    else if (lic.SeatCap is int cap && cap != 1)
    {
        // Cannot express a multi-seat cap in schema 1: refuse with a reason the UI can show.
        throw new ActivationRefused(RefusalReason.ClientTooOld, minimumClient: "3.2.0");
    }

    return p;
}

Golden files: making the contract testable

A format contract that is not under test will drift. The fix is unglamorous and very effective: commit real signed payloads and run them both ways in CI.

Forward: for every schema you have ever emitted, keep a directory of signed goldens — a valid key, an expired key, one with each optional claim present, one with an unknown informational claim injected, one with an unknown critical claim injected — and assert the current parser’s verdict on each. The unknown-critical golden must evaluate to UpdateRequired; the unknown-informational one must evaluate to Paid. The first time someone “cleans up” the parser so it rejects unknown fields, this fails.

Backward: reference the oldest client package you still support as a pinned NuGet version in the test project, not as source. Build payloads with the current server emitter at every schema it can produce, and feed each to the old client’s VerifyAndRead and Evaluate. The old client must verify every one (the bytes are canonical; the signature holds regardless of vocabulary), must accept the informational additions, and must refuse exactly the payloads that carry a critical claim it does not know.

[Theory]
[MemberData(nameof(Goldens), "schema-1")]
[MemberData(nameof(Goldens), "schema-2")]
public void Current_parser_accepts_every_shipped_schema(GoldenCase g)
{
    var payload = Licensing.VerifyAndRead(g.PayloadBytes, g.Signature, TestKeys.Issuer);
    var verdict = Licensing.Evaluate(payload);
    Assert.Equal(g.ExpectedFailure, verdict.Failure);   // None, Expired, UpdateRequired, ...
}

[Fact]
public void Oldest_supported_client_fails_closed_on_critical_claims()
{
    var bytes = Server.Emit(schema: 2, seatCap: 25);           // current emitter
    var v = LegacyClient.Validate(bytes.Payload, bytes.Signature); // pinned 3.1.x package
    Assert.Equal(LegacyFailure.UpdateRequired, v.Failure);
}

Two assertions in that suite carry most of the weight: a signature that verified yesterday verifies today, and an old client never over-grants. Everything else about format evolution is a consequence of keeping those two green.

What you actually ship

The mechanics fit in a few lines: a schema claim inside the signed bytes; signature verification over the raw bytes before parsing; a parser that retains unknown claims instead of rejecting them; a critical list so an old client refuses the restrictions it cannot enforce and ignores the information it can; a one-integer capability report at activation so the server emits what the client can read or refuses with a reason; and signed golden files run forward and backward in CI. Within a schema generation you only add, never repurpose, never narrow a default. With those in place, “we need a new claim” stops being a fleet-wide upgrade event and becomes an ordinary change — the clients that can read it do, the clients that cannot fail closed with a message that says exactly why, and the signature never once stops meaning what it meant.

Try Nebula.NET

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