A licensed service makes one decision on the way in — is this caller allowed to run right now? — and the HTTP response it sends when the answer is no is almost always an afterthought. The two common failures are a bare 500 with a stack trace (which tells the caller nothing and leaks your internals) and a 200 OK carrying { "ok": false } in the body (which no HTTP client, proxy or retry policy will ever notice, because as far as the stack is concerned the request succeeded). Between them sits a contract the gate actually owes its callers: the right status code, a machine-readable reason, and a header a client can branch on — without ever echoing the license key back.
This post builds that contract around a Keyright licence. The verification itself — checking the signature, the expiry, the machine binding — is covered elsewhere; here the licence has already been verified and reduced to a LicenseStatus, and the only question is how to turn that status into an HTTP response a CLI, a browser and a sister service can all handle correctly.
A license failure is not an authentication failure
The reflex is to reach for 401 Unauthorized, because the request was "rejected". That is the wrong axis. 401 means I do not know who you are; it is an authentication signal, and the spec requires it to carry a WWW-Authenticate header telling the client how to present credentials. A client that gets a 401 will re-authenticate — and if its credentials are valid, it will succeed, retry, and hit exactly the same wall. You have built a loop.
A licence failure is orthogonal to identity. The caller can be perfectly authenticated — a valid token, a known API key — and the thing that lapsed is the licence. The status code has to say "I know exactly who you are, and this is still not allowed," which is 403 Forbidden. That covers a revoked key, a machine mismatch, or a request for a feature the licence does not include.
There is one case where 403 undersells the situation. When the licence has simply expired — the subscription ran out — the remedy is not "ask an administrator for permission", it is "renew". For that, 402 Payment Required says more. It was marked reserved in the original HTTP spec, but it is passed through cleanly by every proxy and is widely used by subscription APIs today; the only rule is not to expect a browser to do anything magical with it. Emitting 402 lets a client distinguish a commercial wall from a permissions wall, which is precisely the distinction a licence gate exists to make.
The reason belongs in a problem+json body
A status code is a single number; a client that wants to show a useful message or branch on the specific reason needs structured detail. The standard shape for that is RFC 9457 — Problem Details for HTTP APIs, the successor to RFC 7807 — served as application/problem+json:
{
"type": "https://delta1labs.com/problems/license-expired",
"title": "License expired",
"status": 402,
"detail": "This license expired on 2026-09-30. Renew to continue.",
"licenseStatus": "Expired",
"expiresOn": "2026-09-30T00:00:00Z",
"renewUrl": "https://delta1labs.com/account/renew",
"traceId": "00-3f1a…-01"
}
The type URI is the contract. A client branches on it — a stable string you promise not to change — not on the human title, which you are free to reword or localise. The members below detail are extension members: RFC 9457 lets you add any JSON you like, and this is where the licence-specific facts go. licenseStatus mirrors Keyright's enum, expiresOn gives a CLI something to print, renewUrl gives a UI a button. What is not there is the key, the signature, or the licensee's email — the error body is the single most-copied artifact in a support flow, pasted into tickets and screenshots, so it is held to the same rule as a log line.
ASP.NET Core has first-class support for this. Register the services once:
builder.Services.AddProblemDetails();
AddProblemDetails() registers an IProblemDetailsService and wires the framework to emit application/problem+json for error responses automatically, including content negotiation.
The gate: an endpoint filter that maps status to response
The cleanest place to enforce the licence on a minimal API is an IEndpointFilter: it runs after model binding and authentication but before the handler, it has the HttpContext, and it can short-circuit by returning an IResult instead of calling next.
public sealed class LicenseGateFilter(ILicenseState license) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
{
var status = license.Current; // already-verified snapshot, not a fresh crypto check
// Happy paths run the handler.
if (status is LicenseStatus.Valid or LicenseStatus.ExpiringSoon)
{
if (status is LicenseStatus.ExpiringSoon)
ctx.HttpContext.Response.Headers["Keyright-License"] = "warning; reason=expiring-soon";
return await next(ctx);
}
// Everything else short-circuits with a problem+json response.
var (code, type, title, detail) = Map(status, license);
if (status is LicenseStatus.ServerUnavailable)
ctx.HttpContext.Response.Headers.RetryAfter = "30";
ctx.HttpContext.Response.Headers["Keyright-License"] = $"blocked; status={status}";
return Results.Problem(
statusCode: code,
type: type,
title: title,
detail: detail,
extensions: new Dictionary<string, object?>
{
["licenseStatus"] = status.ToString(),
["expiresOn"] = license.ExpiresOn, // null for non-expiry failures
["renewUrl"] = code == 402 ? "https://delta1labs.com/account/renew" : null,
});
}
private static (int, string, string, string) Map(LicenseStatus s, ILicenseState l) => s switch
{
LicenseStatus.Expired => (402, "https://delta1labs.com/problems/license-expired",
"License expired", $"This license expired on {l.ExpiresOn:yyyy-MM-dd}. Renew to continue."),
LicenseStatus.Revoked => (403, "https://delta1labs.com/problems/license-revoked",
"License revoked", "This license has been revoked. Contact your administrator."),
LicenseStatus.MachineMismatch => (403, "https://delta1labs.com/problems/machine-mismatch",
"License not valid on this machine", "This license is bound to a different machine."),
LicenseStatus.EntitlementMissing=> (403, "https://delta1labs.com/problems/entitlement-missing",
"Feature not licensed", "Your license does not include this feature."),
LicenseStatus.SignatureInvalid => (403, "https://delta1labs.com/problems/license-invalid",
"License invalid", "The license could not be verified."),
LicenseStatus.ServerUnavailable => (503, "https://delta1labs.com/problems/license-unavailable",
"License service unavailable", "Could not reach the license service. Please retry shortly."),
_ => (403, "https://delta1labs.com/problems/license-invalid",
"License invalid", "The license could not be verified."),
};
}
Attach it to the endpoints — or a whole group — that the licence protects:
var pro = app.MapGroup("/api/pro").AddEndpointFilter<LicenseGateFilter>();
pro.MapPost("/export", (ExportRequest r) => Export(r));
Two deliberate choices. First, license.Current reads an already-verified snapshot — the filter never re-runs a signature check per request, so the gate adds a dictionary lookup, not cryptography, to the hot path. Second, the Keyright-License response header carries the machine-readable state even on the happy path (as a warning for an expiring licence), so a sister service or a CLI can react — renew, warn, back off — by reading one header instead of parsing the body. It carries the status, never the key.
4xx is you, 5xx is us
The split between ServerUnavailable → 503 + Retry-After and the 4xx family is what makes a generic HTTP client behave. A 402 or 403 is a definite, client-caused answer: it will not change on retry, there is no Retry-After, and a well-behaved client surfaces it to a human. A 503 with Retry-After: 30 is the opposite — not now, ask again — and a standard retry policy (Polly, HttpClient resilience) will back off and retry it automatically while leaving the 4xx alone. The grace logic that decides how long the app keeps serving while the licence service is unreachable lives behind the gate; the gate only reports the state it is handed.
The test: assert the status, the type, and the absent key
A gate like this earns a WebApplicationFactory test that pins all three parts of the contract — the status code, the problem type, and the one thing that must never be in the body.
[Fact]
public async Task Expired_license_returns_402_problem_and_never_the_key()
{
await using var app = new LicensedAppFactory(LicenseStatus.Expired, key: "KR-SECRET-KEY-DO-NOT-LEAK");
var client = app.CreateClient();
var res = await client.PostAsJsonAsync("/api/pro/export", new { format = "csv" });
Assert.Equal(HttpStatusCode.PaymentRequired, res.StatusCode); // 402
Assert.Equal("application/problem+json", res.Content.Headers.ContentType!.MediaType);
var body = await res.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
Assert.Equal("https://delta1labs.com/problems/license-expired",
doc.RootElement.GetProperty("type").GetString());
Assert.Equal("Expired", doc.RootElement.GetProperty("licenseStatus").GetString());
// The bearer credential must never appear in an error the client can copy.
Assert.DoesNotContain("KR-SECRET-KEY-DO-NOT-LEAK", body);
Assert.Equal("blocked; status=Expired", res.Headers.GetValues("Keyright-License").Single());
}
That last assertion is the one worth keeping forever. Status codes and problem types are a contract you can evolve; a leaked key in an error body is a credential disclosure that will sit in a support ticket for years. A licence gate's job is to say no clearly — to the HTTP stack, to the client's retry policy, and to the human reading the message — while giving away nothing a reader did not already have.
If you are wiring licence verification into a .NET service, Keyright hands you the verified LicenseStatus; the HTTP contract above is how you make it legible to everything downstream.
Top comments (0)