Skip to content
← All posts
· Delta1 Labs KeyrightLicensing.NETDeep Dive

One license key, three products: the product claim, suite bundles as entitlements, and the gate that lets a Lite key unlock Pro

You ship a Designer, a Renderer and a CLI, and your customer wants one key for all of them, not three. The obvious shortcut — give every app the same product slug and let the signature do the rest — is the shortcut that quietly sells your most expensive app for the price of your cheapest one, because a key that verifies for the product line verifies in every app on the line. This post reads what the product claim on a Keyright license actually checks (it is an audience check, not a feature check), lays out the three ways to license a suite, shows the hole in the shared-slug design with the exact gate that opens it, and builds the version that holds: one slug for the line, per-app membership as namespaced entitlements, and a gate in every app that asks "am I in this bundle?" before it asks anything else. It ends with the upgrade path from a single-app key to a suite key, and the two-client fallback for teams that already shipped one slug per app.

You ship three things: a Designer, a Renderer, and a CLI. They are sold separately as Lite tiers and together as a Suite, and the first thing a Suite customer asks is why they have three license keys to paste into three apps. Fair question. One key per customer is the right answer, and the way most teams get there — give every app the same product slug and let the signature handle the rest — is the way that quietly sells your Renderer for the price of your Designer.

This post is about the gate that makes one key per customer hold: what the product claim on a Keyright license really checks, where the shared-slug shortcut leaks, and how per-app membership as entitlements closes it.

The product claim is an audience check

Every Keyright license carries a product slug, signed with the rest of the payload. Every Keyright client carries the slug it expects. The two meet at validation:

static readonly KeyrightClient License = KeyrightClient.Initialize(new KeyrightOptions
{
    Product         = "acme-renderer",                     // the audience THIS app accepts
    PublicKeyBase64 = AcmeKeys.LicensePublicKey,           // public key only; safe to ship
    ServiceUrl      = "https://licensing.acme.example",
});

LicenseInfo info = License.Validate();   // fails closed if the key's product != "acme-renderer"

If a customer pastes a Designer key into the Renderer, the signature is fine, the expiry is fine, and validation still fails — info.IsValid is false and info.Message says the key was issued for another product. That is exactly the role of an aud claim on a JWT: is this token meant for me? It is checked before any other claim is read.

What the product claim does not do is say what the licensee paid for. It is identity, not entitlement. Hold onto that distinction, because the hole below comes from asking the product claim to do a job it was never designed for.

Three ways to license a suite

There are only three shapes, and two of them are tempting for bad reasons.

One slug per app, three keys. acme-designer, acme-renderer, acme-cli, each issued separately. It is safe — a Designer key cannot touch the Renderer — and it is what you get by default when you add licensing to apps one at a time. The cost is the support ticket we opened with: three keys to deliver, three to paste, three to renew.

One slug for the line, nothing else. acme-suite everywhere; the Lite customer gets an acme-suite key with fewer feature entitlements, the Suite customer gets an acme-suite key with all of them. One key per customer. This is the one that leaks, and the next section shows exactly how.

One slug for the line, plus per-app membership entitlements. acme-suite everywhere, and the key says which apps it covers. This is the design that survives, and it costs one IsEnabled call per app.

The hole, concretely

Take the second shape. Here is a Lite key — Designer only — issued under the line’s slug. Nothing is wrong with it:

{
  "product":  "acme-suite",
  "licensee": "acct_8f21",
  "expiry":   "2027-10-16T00:00:00Z",
  "entitlements": {
    "export":   true,
    "max-dpi":  300
  }
}

And here is the Renderer’s gate, written the way gates usually get written — validate, then branch on IsValid:

// Renderer: initialised with Product = "acme-suite"
LicenseInfo info = License.Validate();

if (info.IsValid)             // signature ok, product ok, not expired → true
    RunLicensed();            // ...and the Lite customer is now running the Renderer
else
    RunEvaluation();

Walk it through. The key’s product is acme-suite; the Renderer expects acme-suite; the signature verifies; the expiry is a year out. Every check passes. IsValid is true, and the Renderer runs fully licensed on a key that was sold as “Designer only.” No forgery, no tampering — the customer bought the cheapest SKU and the product claim, doing exactly its job, told the Renderer the key was meant for it. It was. The key just never said which apps it covered, and nobody asked.

One key, three apps: the product claim passes everywhereLite key (signed)product: acme-suiteapp:designer = trueapp:cli = true(no app:renderer)Designer · Product=acme-suiteIsValid ✓ IsEnabled("app:designer") ✓CLI · Product=acme-suiteIsValid ✓ IsEnabled("app:cli") ✓Renderer · Product=acme-suiteIsValid ✓ (product claim passes)gate on IsValid only → UNLOCKSgate on app:renderer → refuseslicensedlicensedleakcorrectTwo questionsproduct claim:"is this key formy product line?"membershipentitlement:"is THIS app inthe bundle?"both are signed;neither can beedited by hand

The gate that holds: membership as an entitlement

The fix is not a slug per app — that throws away the one-key goal. It is to put the bundle on the key, as entitlements, and make every app check its own. The Suite key:

{
  "product":  "acme-suite",
  "licensee": "acct_8f21",
  "expiry":   "2027-10-16T00:00:00Z",
  "entitlements": {
    "app:designer": true,
    "app:renderer": true,
    "app:cli":      true,
    "export":       true,
    "max-dpi":      1200
  }
}

The Lite key is the same shape with only app:designer set. The app: prefix is a convention, not a Keyright feature — it keeps membership visibly separate from features like export and max-dpi, so nobody later confuses “which apps” with “which capabilities.”

Every app now asks two questions, in order, and the second one is the real gate:

// Renderer. Same client, same slug — plus the question that was missing.
LicenseInfo info = License.Validate();

bool licensed = info.IsValid                       // 1. is this key for my product line?
             && License.IsEnabled("app:renderer"); // 2. is THIS app in the bundle?

if (!licensed) { RunEvaluation(info); return; }

// Feature gates come after membership, never instead of it.
if (License.IsEnabled("export"))             EnableExport();
int maxDpi = (int)License.GetLimit("max-dpi", fallback: 150);

Run the Lite key through this Renderer. Product claim passes, signature passes, IsEnabled("app:renderer") is false, licensed is false. The Designer, asking IsEnabled("app:designer"), unlocks. One key; each app sees only the slice it was sold. And because the entitlements are inside the signed payload, a customer cannot add "app:renderer": true to a Lite key any more than they can push the expiry out — one changed byte and the signature fails.

Keep the per-app check to exactly one line, in exactly one place, and run it before any feature gate. If a feature gate ever runs without the membership gate having passed, you have rebuilt the hole with extra steps.

Issuing: one SKU, one entitlement set

On the issuing side a SKU is just a named entitlement set, so the three tiers are three templates, not three products:

{
  "product": "acme-suite",
  "skus": {
    "designer-lite": { "app:designer": true, "export": false, "max-dpi": 300  },
    "renderer-lite": { "app:renderer": true, "export": true,  "max-dpi": 600  },
    "suite":         { "app:designer": true, "app:renderer": true, "app:cli": true,
                       "export": true, "max-dpi": 1200 }
  }
}

Upgrading a customer from Designer Lite to Suite is a re-issue under the same slug with the suite set. The apps do not change, nothing is re-deployed, and the customer pastes one new key into any one of the three apps — the stored license is shared — and all three light up.

If you already shipped one slug per app

Plenty of teams added licensing one app at a time and have acme-designer, acme-renderer and acme-cli keys in the field. You can still get to one key per customer without a flag day. Each app keeps accepting its legacy slug and additionally accepts the suite slug — but only with its own membership entitlement:

static readonly KeyrightClient Suite  = KeyrightClient.Initialize(new KeyrightOptions
    { Product = "acme-suite",    PublicKeyBase64 = AcmeKeys.LicensePublicKey, ServiceUrl = Url });
static readonly KeyrightClient Legacy = KeyrightClient.Initialize(new KeyrightOptions
    { Product = "acme-renderer", PublicKeyBase64 = AcmeKeys.LicensePublicKey, ServiceUrl = Url });

static (LicenseInfo info, KeyrightClient via) Gate()
{
    var s = Suite.Validate();
    if (s.IsValid && Suite.IsEnabled("app:renderer")) return (s, Suite);   // suite key, in bundle

    var l = Legacy.Validate();
    if (l.IsValid) return (l, Legacy);                                      // old per-app key

    return (s, Suite);                                                      // fail closed; report the suite diagnosis
}

New customers get suite keys. Existing customers keep working. When a legacy customer upgrades, you issue a suite key and the legacy path simply stops being the one that answers first. Track how many legacy keys remain from your issuing records, and when it reaches zero, delete Legacy. The one thing not to do is “simplify” a legacy app by switching its single client to the suite slug and leaving the gate on IsValid — that is the second shape again, with the hole.

Test the leak, not just the happy path

The test that matters is the one that would have caught the shared-slug design. Issue a Lite key with a test signing key and assert that the Renderer gate refuses it:

[Fact]
public void Renderer_refuses_a_designer_only_key()
{
    string lite = TestIssuer.Issue(product: "acme-suite",
        entitlements: new() { ["app:designer"] = true, ["export"] = true });
    string path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.lic");
    File.WriteAllText(path, lite);

    var renderer = KeyrightClient.Initialize(new KeyrightOptions
        { Product = "acme-suite", PublicKeyBase64 = TestIssuer.PublicKeyBase64, LicenseFilePath = path });

    var info = renderer.Validate();
    Assert.True(info.IsValid);                           // product claim passes — by design
    Assert.False(renderer.IsEnabled("app:renderer"));   // membership does not — the real gate
}

Note what the first assertion documents: IsValid is true, and that is correct. The test exists to make sure nobody ever ships a build where that is the only thing the Renderer checks. Pair it with the inverse — a suite key enables all three — and with a fake issuing service if you want the activation round-trip covered too.

The takeaway

The product claim on a license is an audience check — “is this key for my product line?” — and it is checked first, before any other claim, which is exactly what makes it the wrong tool for deciding which apps in that line a customer bought. Give the whole line one slug so a customer holds one key, put the bundle on the key as per-app membership entitlements, and make every app gate on its own membership before any feature gate runs. That turns suites, Lite tiers and upgrades into entitlement sets on the issuing side, keeps the one-line check inside each app, and closes the shortcut that would otherwise sell your Renderer for the price of your Designer.

Try Nebula.NET

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