# How to add software licensing to a .NET app

> Source: <https://dev.to/zero_heartbeat_06a3625d7a/how-to-add-software-licensing-to-a-net-app-2hc6>
> Published: 2026-09-23 14:15:30+00:00

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](https://delta1labs.com/docs/keyright/integration-dotnet), and it is honest about which parts are real enforcement and which are only a speed bump.

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.

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
```

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.

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
});
```

`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.

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
}
```

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.

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();
```

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);
```

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.
}
```

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:

`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.

**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");
```

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](https://delta1labs.com/docs/keyright/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.

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

`Validate()` returns `Free` and paid features stay locked.`ClockTampered` and refuses to validate until the time is corrected.
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](https://delta1labs.com/docs/keyright/integration-dotnet) 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.
