A license is a capability token: Keyright entitlements as ASP.NET Core authorization policies
Most apps check a license once at startup and scatter IsEnabled calls through the code. In an ASP.NET Core service that is a mistake — you already have an authorization pipeline built to answer "is this caller allowed to do this?", and a licensed entitlement is exactly that question. Here is how to project Keyright entitlements onto the authorization system: a requirement that names an entitlement, a handler that reads the validated license, a policy provider that conjures policies on demand, and [Authorize(Policy = "ent:export")] on the endpoints that need it — all fail-closed, all offline.
A desktop app can get away with checking its license once at startup and flipping a few booleans. A service cannot. Requests arrive concurrently, endpoints have different capability requirements, and “is this call allowed?” is a question you are answering on every request already — that is what the ASP.NET Core authorization pipeline is for. When a feature is gated by a license, the licensed entitlement is just one more input to that same question. Scattering if (license.IsEnabled("export")) through your controllers reinvents, badly, a system you already have.
This post wires Keyright entitlements into ASP.NET Core authorization so that a licensed capability becomes a policy you attach declaratively:
[HttpPost("export")]
[Authorize(Policy = "ent:export")]
public IActionResult Export([FromBody] ExportRequest req) => Ok(_exporter.Run(req));
No if in the body. If the license doesn’t carry export, the request never reaches Export — it gets a 403 from the pipeline, before your code runs.
The three pieces
ASP.NET Core authorization has a shape that maps cleanly onto entitlements. A requirement is a plain data object describing what must be true. A handler decides whether a requirement is met for the current request. A policy is a named bundle of requirements that [Authorize(Policy = …)] references. We need one requirement that carries an entitlement name, one handler that reads the validated Keyright license, and — because we don’t want to register a policy per feature by hand — a policy provider that manufactures a policy the first time an ent:<name> policy is asked for.
Step 1 — a requirement that names an entitlement
The requirement is pure data. It carries the entitlement key the handler will look up.
using Microsoft.AspNetCore.Authorization;
public sealed class EntitlementRequirement : IAuthorizationRequirement
{
public EntitlementRequirement(string entitlement) => Entitlement = entitlement;
public string Entitlement { get; }
}
That’s the whole type. It holds no license and makes no decision — it only says which entitlement this policy is about.
Step 2 — a handler that reads the validated license
The handler is where the Keyright SDK does its work. Register your KeyrightClient as a singleton at startup (it loads the public key once and is safe to share), inject it, and in HandleRequirementAsync read the validated license and call context.Succeed only when the entitlement is on. Do nothing on the deny path — leaving the requirement unsatisfied is what produces the 403.
using Microsoft.AspNetCore.Authorization;
public sealed class EntitlementHandler : AuthorizationHandler<EntitlementRequirement>
{
private readonly KeyrightClient _license;
public EntitlementHandler(KeyrightClient license) => _license = license;
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, EntitlementRequirement requirement)
{
// Offline: verifies the signature, expiry, node-lock and trial/clock state
// against the embedded public key. Never throws; fails closed to Free.
LicenseInfo info = _license.Validate();
if (info.IsValid && info.Entitlements.IsEnabled(requirement.Entitlement))
context.Succeed(requirement);
// No else. A missing/expired/revoked license or a disabled entitlement
// leaves the requirement unmet → 403.
return Task.CompletedTask;
}
}
Two properties of the Keyright SDK make this safe. Validate() never throws — on any failure it returns a LicenseInfo in the Free edition carrying the reason — so the handler can’t crash the pipeline. And IsEnabled fails closed — an unknown or absent entitlement reads as disabled. The deny path is therefore the default behaviour, not something you code defensively.
Validate() is pure local computation, so calling it per request is cheap, but if you want to avoid re-verifying the signature on every call you can cache the LicenseInfo behind a short time window and hand the handler the cache instead of the client — the decision stays entirely offline either way.
Step 3 — a policy provider so you don’t register every feature by hand
You could call AddPolicy("ent:export", …) once per feature in Program.cs, but that means editing startup every time you gate a new capability. A policy provider builds the policy on demand from the name. Any policy of the form ent:<entitlement> becomes a policy with a single EntitlementRequirement("<entitlement>"):
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
public sealed class EntitlementPolicyProvider : IAuthorizationPolicyProvider
{
private const string Prefix = "ent:";
private readonly DefaultAuthorizationPolicyProvider _fallback;
public EntitlementPolicyProvider(IOptions<AuthorizationOptions> options)
=> _fallback = new DefaultAuthorizationPolicyProvider(options);
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
if (policyName.StartsWith(Prefix, StringComparison.Ordinal))
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser() // compose with auth
.AddRequirements(new EntitlementRequirement(policyName[Prefix.Length..]))
.Build();
return Task.FromResult<AuthorizationPolicy?>(policy);
}
// Any non-entitlement policy name falls through to the normal provider.
return _fallback.GetPolicyAsync(policyName);
}
public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
}
Because the provider composes RequireAuthenticatedUser() with the entitlement requirement, ent:export already means authenticated and licensed for export. Delegating unknown names to the DefaultAuthorizationPolicyProvider keeps your role- and scheme-based policies working untouched.
Wire the three pieces up in Program.cs:
builder.Services.AddSingleton(KeyrightClient.Initialize(new KeyrightOptions
{
Product = "acme-service",
PublicKeyBase64 = builder.Configuration["Keyright:PublicKey"], // not a secret; verify-only
ServiceUrl = builder.Configuration["Keyright:ServiceUrl"],
}));
builder.Services.AddSingleton<IAuthorizationPolicyProvider, EntitlementPolicyProvider>();
builder.Services.AddSingleton<IAuthorizationHandler, EntitlementHandler>();
builder.Services.AddAuthorization();
Now [Authorize(Policy = "ent:export")] on any endpoint is a complete, declarative license gate.
Numeric limits are a requirement too
Flags are the easy half. Entitlements also carry numeric limits — seats, projects, requests — and those gate on current usage, not a static yes/no. Model that as a second requirement whose handler compares GetLimit against a usage counter you inject:
public sealed class WithinLimitRequirement(string limit) : IAuthorizationRequirement
{
public string Limit { get; } = limit;
}
public sealed class WithinLimitHandler(KeyrightClient license, IUsageCounter usage)
: AuthorizationHandler<WithinLimitRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, WithinLimitRequirement requirement)
{
long cap = license.GetLimit(requirement.Limit, fallback: 1); // fail-closed fallback
if (usage.Current(requirement.Limit) < cap)
context.Succeed(requirement);
return Task.CompletedTask;
}
}
GetLimit returns your conservative fallback for a missing or unparseable limit and a maximum value for the literal "unlimited", so an unlicensed caller is capped at 1 and an unlimited plan never blocks. Because every requirement in a policy must pass, you can demand authenticated, licensed for projects, and under the project cap in a single attribute by extending the provider to recognise, say, limit:max-projects.
Why this is the right seam
Putting entitlement checks in authorization isn’t just tidier — it changes the failure mode. With scattered if checks, forgetting one means a feature silently ships unlicensed; the default is allow. With a policy on the endpoint, forgetting the attribute fails loud in review, and once it’s there the default is deny — the request is rejected before your action runs, with a clean 403 your client can handle uniformly. The decision stays offline and fail-closed exactly as everywhere else in Keyright, it composes with the roles and schemes you already enforce, and adding a new gated feature is one attribute plus, at most, one line of config. The license stops being a thing you check and becomes a capability the request either carries or doesn’t.
See the feature-gating guide for the entitlement model this builds on, and fail-closed vs fail-open for why the deny-by-default path matters.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.