Most licensing guidance starts from the machine: fingerprint the device, bind the license to it, and now one activation equals one box. That model is clean and it is the right default for a lot of desktop software. But it quietly assumes something that is often false — that a customer is a machine. Sell a developer tool and your customer is a person who has a laptop, a desktop at the office, and a CI agent that runs the same tool in a container. Under node-locking that one paying developer burns three activations and files a support ticket the first week. The unit you actually sold was a seat, and a seat belongs to a person.
Named-user licensing makes that explicit. The license is anchored to an identity — an email, a stable subject id — and devices hang off the identity as capped, revocable children. The developer signs in on three machines; the server hands each a short-lived, device-scoped lease; the seat stays at one. This post walks through modelling that in Keyright: what the signed identity claim looks like, how a sign-in becomes a device lease, how the device cap is enforced without pinning anyone to a single box, and how a user rolls a stale device off a full seat.
Two anchors, not one
The mental shift is to stop thinking of "the license" as a single signed blob bound to hardware, and start thinking of two signed artifacts with different lifetimes. The entitlement is bound to the user identity and lives as long as the subscription. The device lease is bound to one device under that identity, is short-lived, and is the thing the running app actually verifies on every start.
The entitlement is what you sell and renew. The device lease is a derived, disposable credential. Because the lease is short-lived and signed with the device id inside it, you get revocation almost for free: stop issuing leases for a device and it stops working at the next refresh, without ever reaching into the running client.
The signed identity claim
Keyright signs a canonical payload; for named-user licensing the payload's anchor is the subject (sub) rather than a machine fingerprint. A minimal entitlement payload looks like this:
public sealed record Entitlement
{
public required string Sub { get; init; } // stable user id, e.g. "usr_7Yq..." (not the raw email)
public required string Product { get; init; } // "acme-tool"
public required string Tier { get; init; } // "Pro"
public required int DeviceCap { get; init; } // seats-per-user: 3
public required DateTimeOffset NotAfter { get; init; } // subscription end
public IReadOnlyList<string> Features { get; init; } = Array.Empty<string>();
}
Two deliberate choices. First, Sub is an opaque, stable id — not the raw email. Emails change; a subject id does not, and keeping the email out of the signed claim means you are not re-issuing entitlements every time someone changes their address. Second, DeviceCap lives in the signed entitlement, so the limit travels with the license and cannot be edited client-side. The server reads the cap from the verified entitlement when it decides whether to mint another device lease.
The device lease is a second payload, signed separately, that references the entitlement:
public sealed record DeviceLease
{
public required string Sub { get; init; } // same subject as the entitlement
public required string Device { get; init; } // device id derived from the fingerprint
public required string Product { get; init; }
public required string Tier { get; init; }
public required DateTimeOffset NotAfter { get; init; } // SHORT: hours/days
public IReadOnlyList<string> Features { get; init; } = Array.Empty<string>();
}
The running app only ever verifies a DeviceLease. It never sees the subscription term directly; it sees a short expiry that forces it back to the server periodically, where the long-lived entitlement is re-checked.
Turning a sign-in into a device lease
The device computes a fingerprint (the same machine-identity material you would use for node-locking), hashes it into a compact device id, and asks the server for a lease. The request carries the user's authenticated identity and the device id; the response is a signed DeviceLease or a refusal.
public async Task<LeaseResult> RequestLeaseAsync(
AuthenticatedUser user, CancellationToken ct)
{
// Device id is a stable, non-reversible hash of the fingerprint inputs.
string deviceId = DeviceId.Compute(MachineFingerprint.Collect());
var resp = await _client.PostAsJsonAsync("v1/lease", new LeaseRequest
{
Product = "acme-tool",
Device = deviceId
}, ct); // user identity travels on the authenticated channel, not in the body
if (resp.StatusCode == HttpStatusCode.Conflict)
{
// Cap is full: the server tells us which devices hold the slots
// so the user can choose one to release.
var full = await resp.Content.ReadFromJsonAsync<SeatFullResponse>(ct);
return LeaseResult.SeatFull(full!.Devices);
}
resp.EnsureSuccessStatusCode();
var lease = await resp.Content.ReadFromJsonAsync<SignedLease>(ct);
return LeaseResult.Issued(lease!);
}
On the server side, the lease endpoint is where the two anchors meet. It resolves the entitlement for the authenticated subject, checks that the subscription is live, and then enforces the device cap by counting distinct registered devices, re-issuing for a device that already holds a slot rather than consuming a new one:
public async Task<IResult> IssueLease(LeaseRequest req, ClaimsPrincipal caller)
{
string sub = caller.RequireSubject();
Entitlement ent = await _entitlements.GetActiveAsync(sub, req.Product)
?? return Results.Forbid(); // no live subscription → no lease
// A device that already holds a slot just gets a fresh lease (idempotent).
DeviceRegistration? existing = await _devices.FindAsync(sub, req.Product, req.Device);
if (existing is null)
{
int inUse = await _devices.CountAsync(sub, req.Product);
if (inUse >= ent.DeviceCap)
return Results.Conflict(await _devices.DescribeAsync(sub, req.Product));
await _devices.RegisterAsync(sub, req.Product, req.Device, DateTimeOffset.UtcNow);
}
else
{
await _devices.TouchAsync(existing.Id, DateTimeOffset.UtcNow); // last-seen
}
DeviceLease lease = new()
{
Sub = sub,
Device = req.Device,
Product = ent.Product,
Tier = ent.Tier,
Features = ent.Features,
NotAfter = DateTimeOffset.UtcNow.AddHours(12) // short, independent clock
};
return Results.Ok(_signer.Sign(lease)); // Keyright canonical-payload signing
}
Two properties fall out of this. The endpoint is idempotent per device: a returning device re-issues rather than consuming a slot, so refreshing a lease never costs a seat. And the cap is enforced against the signed DeviceCap, read from the entitlement the server resolved — the client never gets a vote on how many devices it may register.
What the client verifies
The app verifies the device lease the same way it would verify any Keyright license — recover the signer's public key, verify the signature over the canonical bytes, and only then read the claims. The one named-user-specific step is that it checks the lease's device id against this device, so a lease minted for one machine cannot be replayed on another even under the same account:
public LicenseState Evaluate(SignedLease signed)
{
if (!_verifier.TryVerify(signed, out DeviceLease lease))
return LicenseState.Invalid("signature");
// The lease is bound to a device; confirm it is *this* device.
string thisDevice = DeviceId.Compute(MachineFingerprint.Collect());
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(lease.Device),
Encoding.UTF8.GetBytes(thisDevice)))
return LicenseState.Invalid("device-mismatch");
if (lease.NotAfter <= DateTimeOffset.UtcNow)
return LicenseState.Expired; // refresh against the server
return LicenseState.Active(lease.Tier, lease.Features);
}
The expiry here is the device-lease expiry — the short clock. When it lapses the client refreshes; the refresh re-runs the server's entitlement check, so a lapsed subscription or a removed device produces a refusal and the app falls to its unlicensed state at the next boundary. You never have to push a kill signal into a running client; the short lease is the kill switch, and silence from the server is what trips it.
Rolling a device off a full seat
A device cap is only humane if the user can free a slot without a support ticket. Because every lease touches a last-seen timestamp, the full-seat response can list the user's devices with enough context to choose one to drop:
public async Task<IResult> ReleaseDevice(string device, ClaimsPrincipal caller)
{
string sub = caller.RequireSubject();
// Cooldown stops a shared account from churning devices to fake extra seats.
if (await _devices.RemovedRecentlyAsync(sub, TimeSpan.FromMinutes(30)))
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
await _devices.RemoveAsync(sub, "acme-tool", device);
return Results.NoContent();
}
The cooldown is the detail that keeps the cap meaningful. Without it, a shared account could remove and re-add devices every few seconds and effectively run unlimited machines; with a 30-minute floor on removals, the cap becomes a real ceiling and account-sharing hurts the sharer — their own next machine is locked out — far more than it hurts you. The released device's current lease still runs until its short expiry, then fails to refresh because its slot is gone. No abrupt cutoff, no stuck seat.
What to take away
Node-locking asks "which machine is this?" and that is the right question for software sold per box. Named-user licensing asks "whose seat is this?" — the right question when you sell to people who legitimately work across several machines. Anchor the entitlement to a stable subject id with the device cap inside the signed claims, derive short-lived device leases that carry the device id so they cannot be replayed, and enforce the cap on the server by counting distinct registered devices with idempotent re-issue so a refresh never costs a seat. Give users a self-service device release with a cooldown, and let the short lease be your revocation mechanism so you never reach into a running client. Do that and one developer with a laptop, a desktop, and a build agent is exactly what they paid to be: one seat. For the device-identity half of this — how the fingerprint itself is built — see machine fingerprinting and device binding, and for the signed-payload mechanics underneath both anchors, the anatomy of a signed license key.
Top comments (0)