DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Originally published at delta1labs.com

How to add software licensing to a .NET app

To add licensing to a .NET app you issue a signed license key from a server, embed the matching public key in your app, and verify that key offline against it at startup — then gate your paid features on the tier or entitlements the license carries. If you need to enforce seat counts or revoke keys in the field, you layer online activation on top. This how-to walks the whole path with light, illustrative code using the Keyright.NET SDK, and it is honest about which parts are real enforcement and which are only a speed bump.

The mental model: sign server-side, verify client-side

Licensing built on public-key signatures has one asymmetry doing all the work. A private key lives on your server and is the only thing that can create a valid license. The matching public key ships inside your app and can only verify signatures — it can never mint them. That's why embedding the public key in your binary is safe: an attacker can decompile your app, read the public key, even publish it, and still cannot forge a license, because forging requires the private key they don't have.

So the flow is: your server signs a license (a small JSON payload — licensee, product, tier, seats, expiry, entitlements — plus an RSA signature over it); your app verifies that signature offline against the embedded public key; and everything you unlock hangs off the fields inside a license you've confirmed is authentic.

Step 1 — Issue a signed license key

Keys are minted server-side. With Keyright you issue one from the dashboard or the admin API against a product and tier:

curl -X POST $BASE/admin/licenses -H "X-Admin-Token: $TOKEN" -H "content-type: application/json" \
  -d '{"licensee":"Acme Inc.","product":"acme-app","tier":"pro","seats":3,"email":"owner@acme.com"}'
# -> { "id": "LIC-XXXXXXXX...", ... }   this is the key the customer pastes into your app
Enter fullscreen mode Exit fullscreen mode

The tier here (pro) carries an entitlement template — the named flags and limits every license of that tier inherits, which you'll check in Step 4. The private signing key that signs this license never leaves the server.

Step 2 — Embed your public key

Grab your tenant's public key (the dashboard's Integration tab, or GET /admin/public-key) and paste it into the SDK options. Construct exactly one client at startup:

using Keyright.Client;

static readonly KeyrightClient License = KeyrightClient.Initialize(new KeyrightOptions
{
    Product         = "acme-app",                          // must match the slug you issue keys for
    PublicKeyBase64 = "MIIBIjANBgkq...",                   // the public key from Step 1's tenant
    ServiceUrl      = "https://keyright.delta1labs.com",   // omit if you ship offline files only
});
Enter fullscreen mode Exit fullscreen mode

Product and PublicKeyBase64 are the only required options. ServiceUrl is only needed if you'll activate online (Step 5). This public key is not a secret — it ships in your compiled binary and all the security comes from the private key staying server-side.

Step 3 — Verify offline at startup

Call Validate(). It finds the best available license (an explicit string, a license file, an env var, or a cached activation lease), verifies the RSA signature, the product match, the node-lock, expiry, an optional shipped revocation list, and trial/clock state — all offline, with no network call. Critically, it never throws: on any problem it returns a LicenseInfo in the Free edition carrying the reason.

LicenseInfo info = License.Validate();

if (info.IsPaid)                        // true for any edition above Free
{
    // unlock paid features
}
Enter fullscreen mode Exit fullscreen mode

Being honest here: an offline check runs entirely on the user's machine, so a determined attacker can patch it out. Offline validation is the right tool for air-gapped and enterprise installs and for a fast startup check, but on its own it is a speed bump. Real enforcement comes from pairing it with online activation (Step 5) so seats and revocation are decided server-side.

Step 4 — Gate features by tier and entitlements

Rather than branching on the edition name, gate individual features on entitlements — named flags and numeric limits baked into the license by its tier. That way you can change what a plan unlocks from the dashboard without shipping new code:

// Boolean flag
if (License.IsEnabled("export"))
    ShowExportCommand();

// Numeric limit — you pass the fail-closed fallback
long maxProjects = License.GetLimit("max-projects", fallback: 1);
if (currentProjectCount >= maxProjects)
    PromptToUpgrade();
Enter fullscreen mode Exit fullscreen mode

Both IsEnabled and GetLimit validate on the spot and fail closed: a missing flag reads as disabled, and a missing or unparseable limit returns the fallback you supply. If you're checking several entitlements at once, validate once and reuse the result:

LicenseInfo info = License.Validate();
bool canExport  = info.Entitlements.IsEnabled("export");
long maxSeats   = info.Entitlements.GetLimit("max-seats", 1);
Enter fullscreen mode Exit fullscreen mode

Step 5 — Activate online for seats (optional but recommended)

When a customer pastes their key, call ActivateAsync. It posts the key plus a stable machine id to your service, which consumes a seat and returns a short-lived signed lease bound to that machine. The SDK verifies the lease against your embedded public key and caches it locally, so every later Validate() succeeds offline until the lease's grace window elapses.

LicenseInfo info = await License.ActivateAsync(customerEnteredKey, ct);

if (info.IsValid && info.IsPaid)
{
    // Activated. Lease cached; the app now works offline until it nears expiry.
    ShowLicensedUi(info.StatusBadge);      // e.g. "Pro" or "Enterprise Trial"
}
else
{
    ShowActivationError(info.Message);     // "All seats for this license are in use.", etc.
}
Enter fullscreen mode Exit fullscreen mode

Like Validate(), ActivateAsync does not throw for ordinary failures (bad key, seat limit exhausted, offline, revoked) — it returns a fail-closed result you inspect. A few properties worth knowing:

  • Seats are enforced server-side. Re-activating an already-bound machine is idempotent and consumes no extra seat; exceeding the cap returns a seat-limit result and no lease.
  • Offline grace. If the service is unreachable, activation falls back to any still-valid cached lease, so a brief outage doesn't lock the user out.
  • It throws only for programmer errors — a missing ServiceUrl or an empty key.

This is the step that turns "a check on the honor system" into real enforcement: the seat count and revocation status are decided on a server you control, not on the attacker's machine.

Step 6 — Handle trials and revocation

Trials flow through the exact same activation path — there's no separate trial code in your app. A trial key returns a lease with IsTrial set, and you surface it straight off LicenseInfo:

if (info.IsTrial)
    ShowBadge($"{info.StatusBadge}{info.DaysRemaining} days left");
Enter fullscreen mode Exit fullscreen mode

A trial counts down from first activation and is superseded seamlessly when the customer later activates a paid key — no reinstall. You can even let customers start a trial from your own marketing site with one public API call; see self-service free trials.

Revocation is how you kill a leaked or refunded key. Revoke it server-side (POST /admin/licenses/{id}/revoke or a dashboard click) and the client drops to Free on its next lease refresh. For purely offline apps, ship a signed revocation list with your build so even a disconnected client honors it.

Step 7 — Test the fail-closed path

The most important test is that things break toward locked, not open:

  1. Before activation, confirm Validate() returns Free and paid features stay locked.
  2. Activate with a real key, watch the app flip to licensed, then disconnect the network and restart — it should still be licensed from the cached lease.
  3. Revoke the key, reconnect, let the lease refresh, and confirm the app drops back to Free.
  4. Roll the clock backward on a trial and confirm the SDK reports ClockTampered and refuses to validate until the time is corrected.

The honest summary

Adding licensing to a .NET app is two moving parts: verify a signed key offline against an embedded public key, and — when you need seats or revocation — activate online for a short-lived lease. Client-side checks alone are a speed bump that an attacker can eventually patch; the real enforcement is the server deciding seats and revocation, with the SDK failing closed everywhere in between. The Keyright.NET SDK gives you both halves — offline Validate() and online ActivateAsync() with entitlements, trials, node-locking, and revocation — and multi-targets from .NET Framework 4.8 to current .NET. You can wire the whole flow end to end on the free plan before paying anything.

Top comments (0)