Welchen HTTP-Status verdient eine fehlgeschlagene Lizenzprüfung? 402, 403, Problem Details und ein Header, den der Client lesen kann
Wenn eine lizenzierte API entscheidet, dass ein Aufrufer nicht laufen darf, ist das Schlimmste, was sie tun kann, ein nackter 500 mit Stacktrace — und das Zweitschlimmste ein 200 mit { "ok": false }, den kein Client je bemerkt. Dieser Beitrag arbeitet den HTTP-Vertrag heraus, den ein Lizenz-Gate seinen Aufrufern tatsächlich schuldet: warum ein Lizenzfehler kein 401 ist (der Aufrufer ist einwandfrei authentifiziert — es ist die Lizenz, die abgelaufen ist), wann 403 richtig ist und wann 402 Payment Required mehr aussagt, wie man den maschinenlesbaren Grund in einem RFC-9457-Body vom Typ application/problem+json mit Erweiterungsmembern transportiert, auf die ein Client verzweigen kann, und welcher eine Response-Header — niemals der Schlüssel — eine CLI oder einen Schwesterdienst reagieren lässt, ohne Prosa zu parsen. Das Gate wird als ASP.NET-Core-IEndpointFilter gebaut, der Keyrights LicenseStatus über IProblemDetailsService auf Statuscodes abbildet, Retry-After ergänzt, wenn der Lizenzdienst kurz nicht erreichbar ist und das Kulanzfenster noch offen steht, und endet mit einem WebApplicationFactory-Test, der den Status, den Problemtyp und die Abwesenheit des signierten Schlüssels im Body prüft.
Ein lizenzierter Dienst trifft beim Eingang eine Entscheidung — darf dieser Aufrufer gerade jetzt laufen? — und die HTTP-Antwort, die er sendet, wenn die Antwort nein lautet, ist fast immer ein Nachgedanke. Die zwei häufigsten Fehler sind ein nackter 500 mit Stacktrace (der dem Aufrufer nichts sagt und Ihre Interna preisgibt) und ein 200 OK mit { "ok": false } im Body (den kein HTTP-Client, Proxy oder keine Retry-Policy je bemerken wird, weil die Anfrage aus Sicht des Stacks erfolgreich war). Dazwischen liegt der Vertrag, den das Gate seinen Aufrufern tatsächlich schuldet: der richtige Statuscode, ein maschinenlesbarer Grund und ein Header, auf den ein Client verzweigen kann — ohne jemals den Lizenzschlüssel zurückzuspiegeln.
Dieser Beitrag baut diesen Vertrag rund um eine Keyright-Lizenz auf. Die Verifikation selbst — Signatur, Ablauf, Maschinenbindung prüfen — ist andernorts behandelt; hier ist die Lizenz bereits verifiziert und auf einen LicenseStatus reduziert, und die einzige Frage ist, wie dieser Status zu einer HTTP-Antwort wird, die eine CLI, ein Browser und ein Schwesterdienst allesamt korrekt verarbeiten können.
Ein Lizenzfehler ist kein Authentifizierungsfehler
Der Reflex ist, zu 401 Unauthorized zu greifen, weil die Anfrage „abgelehnt” wurde. Das ist die falsche Achse. 401 bedeutet ich weiß nicht, wer du bist; es ist ein Authentifizierungssignal, und die Spezifikation verlangt, dass es einen WWW-Authenticate-Header trägt, der dem Client sagt, wie er Anmeldedaten vorlegen soll. Ein Client, der einen 401 erhält, authentifiziert sich neu — und wenn seine Anmeldedaten gültig sind, gelingt das, er versucht es erneut und läuft gegen genau dieselbe Wand. Sie haben eine Schleife gebaut.
Ein Lizenzfehler ist orthogonal zur Identität. Der Aufrufer kann einwandfrei authentifiziert sein — ein gültiges Token, ein bekannter API-Key — und was abgelaufen ist, ist die Lizenz. Der Statuscode muss sagen: „Ich weiß genau, wer du bist, und das ist trotzdem nicht erlaubt”, und das ist 403 Forbidden. Das deckt einen widerrufenen Schlüssel, eine falsche Maschine oder die Anfrage nach einem Feature ab, das die Lizenz nicht enthält.
Es gibt einen Fall, in dem 403 zu wenig sagt. Wenn die Lizenz schlicht abgelaufen ist — das Abonnement ist ausgelaufen —, lautet die Abhilfe nicht „einen Administrator um Erlaubnis bitten”, sondern „verlängern”. Dafür sagt 402 Payment Required mehr. Er wurde in der ursprünglichen HTTP-Spezifikation als reserviert markiert, wird aber von jedem Proxy sauber durchgereicht und heute von Abonnement-APIs breit verwendet; die einzige Regel ist, von einem Browser nichts Magisches zu erwarten. Ein 402 erlaubt es einem Client, eine kommerzielle Wand von einer Berechtigungs-Wand zu unterscheiden — und genau für diese Unterscheidung gibt es ein Lizenz-Gate.
Der Grund gehört in einen problem+json-Body
Ein Statuscode ist eine einzelne Zahl; ein Client, der eine nützliche Meldung anzeigen oder auf den konkreten Grund verzweigen will, braucht strukturierte Details. Die Standardform dafür ist RFC 9457 — Problem Details for HTTP APIs, der Nachfolger von RFC 7807 — ausgeliefert als 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"
}
Die type-URI ist der Vertrag. Ein Client verzweigt auf sie — einen stabilen String, den Sie versprechen nicht zu ändern — und nicht auf den menschenlesbaren title, den Sie frei umformulieren oder lokalisieren dürfen. Die Member unterhalb von detail sind Erweiterungsmember: RFC 9457 erlaubt beliebiges zusätzliches JSON, und genau hier gehören die lizenzspezifischen Fakten hin. licenseStatus spiegelt Keyrights Enum, expiresOn gibt einer CLI etwas zum Ausgeben, renewUrl gibt einer UI einen Button. Was nicht darin steht: der Schlüssel, die Signatur oder die E-Mail des Lizenznehmers — der Fehler-Body ist das am häufigsten kopierte Artefakt eines Support-Vorgangs, in Tickets und Screenshots eingefügt, und unterliegt daher derselben Regel wie eine Logzeile.
ASP.NET Core unterstützt das von Haus aus. Registrieren Sie die Dienste einmal:
builder.Services.AddProblemDetails();
AddProblemDetails() registriert einen IProblemDetailsService und verdrahtet das Framework so, dass es für Fehlerantworten automatisch application/problem+json ausgibt — inklusive Content Negotiation.
Das Gate: ein Endpoint-Filter, der Status auf Antwort abbildet
Der sauberste Ort, die Lizenz in einer Minimal API durchzusetzen, ist ein IEndpointFilter: Er läuft nach Model Binding und Authentifizierung, aber vor dem Handler, hat den HttpContext und kann kurzschließen, indem er ein IResult zurückgibt, statt next aufzurufen.
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."),
};
}
Hängen Sie ihn an die Endpunkte — oder eine ganze Gruppe —, die die Lizenz schützt:
var pro = app.MapGroup("/api/pro").AddEndpointFilter<LicenseGateFilter>();
pro.MapPost("/export", (ExportRequest r) => Export(r));
Zwei bewusste Entscheidungen. Erstens liest license.Current einen bereits verifizierten Snapshot — der Filter führt niemals pro Anfrage eine neue Signaturprüfung aus, sodass das Gate dem Hot Path einen Dictionary-Lookup hinzufügt, keine Kryptografie. Zweitens trägt der Keyright-License-Response-Header den maschinenlesbaren Zustand auch auf dem Happy Path (als warning für eine ablaufende Lizenz), sodass ein Schwesterdienst oder eine CLI reagieren kann — verlängern, warnen, zurückweichen —, indem sie einen Header liest, statt den Body zu parsen. Er trägt den Status, niemals den Schlüssel.
4xx bist du, 5xx sind wir
Die Trennung zwischen ServerUnavailable → 503 + Retry-After und der 4xx-Familie ist das, was einen generischen HTTP-Client richtig handeln lässt. Ein 402 oder 403 ist eine definitive, vom Client verursachte Antwort: Sie ändert sich bei einem erneuten Versuch nicht, es gibt kein Retry-After, und ein wohlerzogener Client zeigt sie einem Menschen. Ein 503 mit Retry-After: 30 ist das Gegenteil — jetzt nicht, frag später nochmal — und eine Standard-Retry-Policy (Polly, die HttpClient-Resilienz) weicht automatisch zurück und wiederholt ihn, während sie die 4xx in Ruhe lässt. Die Kulanzlogik, die entscheidet, wie lange die App weiter bedient, während der Lizenzdienst nicht erreichbar ist, lebt hinter dem Gate; das Gate meldet nur den Zustand, den es übergeben bekommt.
Der Test: Status, Typ und den abwesenden Schlüssel prüfen
Ein solches Gate verdient einen WebApplicationFactory-Test, der alle drei Teile des Vertrags festnagelt — den Statuscode, den Problem-type und das eine, was niemals im Body stehen darf.
[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());
}
Diese letzte Assertion ist die, die man für immer behalten sollte. Statuscodes und Problemtypen sind ein Vertrag, den Sie weiterentwickeln können; ein geleakter Schlüssel in einem Fehler-Body ist eine Credential-Offenlegung, die jahrelang in einem Support-Ticket liegen wird. Die Aufgabe eines Lizenz-Gates ist es, klar nein zu sagen — dem HTTP-Stack, der Retry-Policy des Clients und dem Menschen, der die Meldung liest —, ohne etwas preiszugeben, was der Leser nicht ohnehin schon hatte.
Wenn Sie Lizenzverifikation in einen .NET-Dienst einbauen, liefert Ihnen Keyright den verifizierten LicenseStatus; der HTTP-Vertrag oben ist die Art, ihn für alles stromabwärts lesbar zu machen.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.