Kurz gesagt. Veröffentliche den neuen Schlüssel in deinem JWKS, bevor du irgendetwas damit signierst, und warte mindestens eine volle Cache-Lebensdauer der Verifizierer ab. Stelle dann das Signieren auf den neuen Schlüssel um. Lass den alten Schlüssel veröffentlicht, bis das letzte damit signierte Token abgelaufen ist, plus Uhrenabweichung, plus eine Cache-Lebensdauer. Gib jedem Schlüssel einen kid, der nie wiederverwendet wird. Im Notfall entferne den Schlüssel und sorge dafür, dass die Verifizierer den Schlüsselsatz neu abrufen: Bis sie das tun, gilt der entfernte Schlüssel weiter als vertrauenswürdig, und die Cache-Lebensdauer deines langsamsten Verifizierers bestimmt, wie lange dieses Fenster offen bleibt.
Wie Verifizierer den Schlüssel finden
Ein Aussteller veröffentlicht seine öffentlichen Schlüssel als JWK Set unter der jwks_uri, die in seinem Discovery-Dokument für OpenID Connect oder in den Metadaten seines OAuth-Autorisierungsservers steht. Jedes Token trägt in seinem Header einen kid. Der Verifizierer ruft den Schlüsselsatz ab, legt ihn im Cache ab und wählt den Schlüssel, dessen kid übereinstimmt. OpenID Connect Core beschreibt die Rotation in vier Sätzen (§10.1.1): Der Signierer fügt dem Schlüsselsatz neue Schlüssel hinzu, beginnt einen davon zu verwenden, wann er will, zeigt den Wechsel über kid an, und der Verifizierer „weiß, dass er zur jwks_uri zurückkehren muss, um die Schlüssel erneut abzurufen, wenn er einen unbekannten kid-Wert sieht“. Der Schlüsselsatz „SOLLTE kürzlich außer Dienst gestellte Signaturschlüssel für einen angemessenen Zeitraum behalten“.
Aus diesem Mechanismus folgen zwei Dinge, die die Spezifikation nicht ausdrücklich nennt:
- Ein unbekannter
kidlöst einen Abruf aus, aber Verifizierer begrenzen diese Abrufe mit einem Rate Limit. Ohne Limit könnte jeder deine API dazu bringen, das JWKS bei jeder Anfrage abzurufen, indem er Tokens mit zufälligen Werten fürkidschickt. Mit Limit gilt: War der Signaturschlüssel eines Tokens beim letzten Abruf des Verifizierers noch nicht veröffentlicht, wird das Token abgelehnt, bis das Limit abläuft. - Ein bekannter
kidlöst nie einen Abruf aus. Einen Schlüssel aus dem Schlüsselsatz zu entfernen bewirkt nichts, bis der Cache des Verifizierers von selbst abläuft. Die Logik, die bei einem Fehltreffer neu abruft, kann ein Entfernen nicht bemerken, denn der entfernte Schlüssel fehlt im Cache ja nicht.
Der zeitliche Ablauf, gemessen
Das folgende Testprogramm betreibt einen Aussteller und zwei Verifizierer in einem Prozess. Der Aussteller liefert ein JWKS über HTTP aus; die Verifizierer nutzen createRemoteJWKSet aus jose mit einem Cache von 4 Sekunden und einem Cooldown von 2 Sekunden zwischen zwei Neuabrufen wegen eines unbekannten kid. In echten Umgebungen sind es Minuten oder Stunden; der Verlauf ist derselbe.
// Rotation timeline against a live JWKS endpoint, verified with jose's createRemoteJWKSet.
// Durations are shortened (cache 4 s, cooldown 2 s) so the whole run takes seconds.
import { createServer } from "node:http";
import { SignJWT, createRemoteJWKSet, exportJWK, generateKeyPair, jwtVerify } from "jose";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const t0 = Date.now();
const log = (msg) => console.log(`t=${((Date.now() - t0) / 1000).toFixed(1).padStart(4)}s ${msg}`);
// Issuer: three keys, the currently published kids, and a count of JWKS fetches.
const keys = {};
for (const kid of ["2026-10-a", "2026-11-b", "2026-12-c"]) {
const pair = await generateKeyPair("ES256");
keys[kid] = { ...pair, jwk: { ...(await exportJWK(pair.publicKey)), kid, alg: "ES256", use: "sig" } };
}
let published = ["2026-10-a"];
let fetches = 0;
const server = createServer((req, res) => {
fetches++;
res.setHeader("content-type", "application/jwk-set+json");
res.setHeader("cache-control", "public, max-age=4");
res.end(JSON.stringify({ keys: published.map((kid) => keys[kid].jwk) }));
}).listen(8090);
const sign = (kid) =>
new SignJWT({ sub: "user-42" }).setProtectedHeader({ alg: "ES256", kid }).setExpirationTime("10m").sign(keys[kid].privateKey);
// Two verifiers with identical settings; only the first one is told to reload.
const remote = () =>
createRemoteJWKSet(new URL("http://127.0.0.1:8090/jwks.json"), { cacheMaxAge: 4000, cooldownDuration: 2000 });
const verifiers = { api1: remote(), api2: remote() };
const verify = async (name, label, token) => {
const before = fetches;
let result = "accepted";
try {
await jwtVerify(token, verifiers[name], { algorithms: ["ES256"] });
} catch (e) {
result = e.code;
}
log(`${name} ${label.padEnd(40)} ${result} (fetches +${fetches - before})`);
};
const tokenA = await sign("2026-10-a");
await verify("api1", "token A, set {A}", tokenA);
log("--- planned rotation: publish B, wait one cache lifetime, then sign with B");
published = ["2026-10-a", "2026-11-b"];
await sleep(4200);
await verify("api1", "token A after the cache expired", tokenA);
await verify("api1", "token B", await sign("2026-11-b"));
log("--- mistake: sign with C before publishing it");
await sleep(2200);
const tokenC = await sign("2026-12-c");
await verify("api1", "token C, C not published", tokenC);
published = ["2026-10-a", "2026-11-b", "2026-12-c"];
log("issuer publishes C");
await verify("api1", "token C, inside the cooldown", tokenC);
await sleep(2200);
await verify("api1", "token C, after the cooldown", tokenC);
log("--- emergency: A is compromised and removed");
await verify("api2", "token A before removal", tokenA);
published = ["2026-11-b", "2026-12-c"];
await verify("api1", "token A right after removal", tokenA);
const before = fetches;
await verifiers.api1.reload();
log(`api1 reload() (fetches +${fetches - before})`);
await verify("api1", "token A after reload()", tokenA);
for (let i = 0; i < 4; i++) {
await sleep(1100);
await verify("api2", "token A, no reload", tokenA);
}
server.close();
t= 0.0s api1 token A, set {A} accepted (fetches +1)
t= 0.0s --- planned rotation: publish B, wait one cache lifetime, then sign with B
t= 4.2s api1 token A after the cache expired accepted (fetches +1)
t= 4.3s api1 token B accepted (fetches +0)
t= 4.3s --- mistake: sign with C before publishing it
t= 6.5s api1 token C, C not published ERR_JWKS_NO_MATCHING_KEY (fetches +1)
t= 6.5s issuer publishes C
t= 6.5s api1 token C, inside the cooldown ERR_JWKS_NO_MATCHING_KEY (fetches +0)
t= 8.7s api1 token C, after the cooldown accepted (fetches +1)
t= 8.7s --- emergency: A is compromised and removed
t= 8.7s api2 token A before removal accepted (fetches +1)
t= 8.7s api1 token A right after removal accepted (fetches +0)
t= 8.7s api1 reload() (fetches +1)
t= 8.7s api1 token A after reload() ERR_JWKS_NO_MATCHING_KEY (fetches +0)
t= 9.8s api2 token A, no reload accepted (fetches +0)
t=10.9s api2 token A, no reload accepted (fetches +0)
t=12.0s api2 token A, no reload accepted (fetches +0)
t=13.1s api2 token A, no reload ERR_JWKS_NO_MATCHING_KEY (fetches +1)
Drei Dinge sind passiert, die genauso in Produktion passieren werden:
- Die geplante Rotation lief ohne einen einzigen Fehler, weil B im Cache des Verifizierers lag, bevor das erste mit B signierte Token ankam. Ein Neuabruf wegen eines unbekannten kid war nicht nötig.
- Signieren vor dem Veröffentlichen schlug fehl, und zwar auch noch nach der Korrektur. Der Verifizierer rief wegen des unbekannten
kidneu ab, fand C nicht und verweigerte dann während des Cooldowns einen weiteren Neuabruf, obwohl C inzwischen veröffentlicht war. Mit dem Standard-Cooldown von 30 Sekunden in jose ergibt das eine halbe Minute abgelehnter Anfragen pro Verifizierer-Instanz; mit dem Standardwert von 5 Minuten in keyfunc sind es fünf Minuten. Die API protokolliert dabei den Fehler no applicable key found in the JSON Web Key Set, und der sieht genauso aus wie bei einem gefälschten Token. - Das Entfernen eines Schlüssels hat ihn nicht widerrufen.
api2akzeptierte den entfernten Schlüssel A weiter, bis sein Cache ablief. Nur der Verifizierer, den wir perreload()zum Neuladen aufgefordert hatten, hörte sofort damit auf.
Regeln für eine Rotation ohne Ausfallzeit
Im Folgenden ist C die längste Zeit, die irgendein Verifizierer oder irgendein Cache vor dem JWKS-Endpunkt den Schlüsselsatz vorhalten darf, L die längste Laufzeit eines Tokens, das du signierst, und S die Uhrenabweichung, die Verifizierer tolerieren.
| Schritt | Zeitpunkt | Aktion |
|---|---|---|
| 1. Veröffentlichen | T₀ | Füge den neuen Schlüssel dem Schlüsselsatz hinzu, mit einem frischen kid, seinem alg und "use": "sig". Signiere weiter mit dem alten Schlüssel. |
| 2. Umstellen | T₀ + C | Signiere ab jetzt mit dem neuen Schlüssel. Jeder Verifizierer hatte Gelegenheit, ihn abzurufen. |
| 3. Ausmustern | T₀ + C + L + S | Das letzte mit dem alten Schlüssel signierte Token ist überall abgelaufen. |
| 4. Entfernen | nach Schritt 3, plus C, wenn Caches ihn vergessen sollen | Lösche den alten Schlüssel aus dem Schlüsselsatz und vernichte seine private Hälfte. |
Ein Rechenbeispiel: ein Anbieter, zu dessen Verifizierern APIs mit ASP.NET Core gehören (Metadaten werden standardmäßig alle 12 Stunden aktualisiert, siehe unten), ein Nginx-Cache vor dem JWKS mit einer Lebensdauer von einer Stunde, Access Tokens, die eine Stunde gültig sind, und 5 Minuten Uhrenabweichung. C beträgt 13 Stunden, also kommt der neue Schlüssel am Montagmorgen in den Schlüsselsatz und signiert ab Montagabend die Tokens. Der alte Schlüssel kann am Dienstagmorgen entfernt werden. Das Fenster ist einen Tag lang, nicht eine Minute, und ein längeres Fenster kostet nur einen zusätzlichen Schlüssel im Schlüsselsatz.
Sind Verifizierer mit einem statischen Schlüssel statt der JWKS-URL konfiguriert, entfällt das Warten auf C; die Rotation braucht dann eine koordinierte Konfigurationsänderung auf jedem Verifizierer. Deshalb ist eine JWKS-URL in jeder Bibliothek für OpenID Connect der Standard.
Regeln für kid
- Eindeutig und nie wiederverwendet. RFC 7517 §4.5 sagt, dass Schlüssel in einem Schlüsselsatz „unterschiedliche Werte für ‚kid‘ verwenden SOLLTEN“. Ein späteres Wiederverwenden ist schlimmer als ein Duplikat innerhalb eines Schlüsselsatzes: Ein Verifizierer, der Schlüssel nach
kidim Cache ablegt, behält unter diesem Namen den alten Schlüssel, und jose ruft nur dann neu ab, wenn kein Schlüssel passt, nicht wenn ein passender Schlüssel die Prüfung nicht besteht. Signaturen des neuen Schlüssels schlagen dann fehl, bis der Cache abläuft. - Für Verifizierer bedeutungslos. Verwende einen Zufallswert, ein Datumslabel wie
2026-11-boder den JWK-Thumbprint nach RFC 7638. Kodiere keine Information hinein, auf die sich Verifizierer verlassen sollen; derkidkommt in einem ungeprüften Header an. - Immer vorhanden. Ohne
kidmuss ein Verifizierer jeden Schlüssel durchprobieren oder aufgeben. jwks-rsa verweigert die Prüfung, wenn der Schlüsselsatz mehr als einen Schlüssel enthält (No KID specified and JWKS endpoint returned more than 1 key), und jose scheitert mit multiple matching keys found in the JSON Web Key Set. Mit zwei Schlüsseln im JWKS und Tokens ohnekidschlägt die Prüfung deshalb genau während jeder Rotation fehl. - Gekoppelt mit
alg. Veröffentliche zu jedem Schlüsselalg, damit Verifizierer den Schlüssel an einen einzigen Algorithmus binden, wie es RFC 8725 §3.1 verlangt.
Was Verifizierer-Bibliotheken tatsächlich tun
Das C in der Tabelle oben legen die Verifizierer fest, nicht du. Für selbst geschriebene Verifizierer ist Microsofts Leitfaden zum Rollover von Signaturschlüsseln eine solide Vorgabe: Schlüssel einzeln nach kid 24 Stunden lang im Cache halten, jede Stunde im Hintergrund neu abrufen und bei einem unbekannten kid neu abrufen, aber nicht öfter als alle 5 Minuten. Die folgenden Standardwerte der Bibliotheken stammen aus dem Quellcode der jeweiligen Bibliothek oder aus einem Programm, das ihre Konstanten ausgibt, jeweils in den angegebenen Versionen:
| Bibliothek | Cache-Lebensdauer | Neuabruf bei unbekanntem kid | Erzwungener Neuabruf |
|---|---|---|---|
jose 6.2.13 createRemoteJWKSet | 10 Minuten | höchstens einmal pro 30 s | jwks.reload() |
| jwks-rsa 4.1.0 | 10 Minuten, pro kid | bei jedem Fehltreffer, außer mit rateLimit: true (dann 10 pro Minute) | neuen Client erzeugen |
PyJWT 2.15.1 PyJWKClient | 300 s | höchstens einmal pro 30 s | get_jwk_set(refresh=True) |
| keyfunc v3.8.2 (Go) | Neuabruf im Hintergrund jede Stunde | höchstens einmal pro 5 Minuten | Storage().KeyDelete(ctx, kid) verwirft einen Schlüssel |
Nimbus JOSE+JWT 10.10 JWKSourceBuilder mit Standardwerten | 5 Minuten, 30 s vor Ablauf aktualisiert | höchstens einmal pro 30 s | neue Quelle erzeugen |
Microsoft.IdentityModel 8.23.0 ConfigurationManager (ASP.NET Core) | 12 Stunden | höchstens einmal pro 5 Minuten | RequestRefresh(), selbst auf einmal pro 5 Minuten begrenzt |
go-oidc v3.21.0 RemoteKeySet | kein Ablauf | bei jedem Fehltreffer, höchstens ein Abruf gleichzeitig | Neustart |
Spring Security 7.1.1 baut die Quelle seines NimbusJwtDecoder mit JWKSourceBuilder, schaltet aber die vorausschauende Aktualisierung und das Rate Limiting ab (.refreshAheadCache(false), .rateLimited(false) in JwkSetUriJwtDecoderBuilder), sodass von diesen Standardwerten nur der Cache mit 5 Minuten Lebensdauer übrig bleibt.
Die letzte Zeile ist der Ausreißer. go-oidc behält den Schlüsselsatz, bis ein Token die Prüfung dagegen nicht besteht; zeitgesteuert läuft nichts ab. Für eine Notfallentfernung bedeutet das Folgendes:
package main
// Emergency removal against go-oidc's RemoteKeySet: the key set has no time-based expiry,
// so a removed key keeps verifying until the process restarts or a kid miss forces a fetch.
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/json"
"fmt"
"net"
"net/http"
"sync/atomic"
"time"
"github.com/coreos/go-oidc/v3/oidc"
jose "github.com/go-jose/go-jose/v4"
)
func main() {
keyA, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
keyB, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
jwkA := jose.JSONWebKey{Key: &keyA.PublicKey, KeyID: "2026-10-a", Algorithm: "ES256", Use: "sig"}
jwkB := jose.JSONWebKey{Key: &keyB.PublicKey, KeyID: "2026-11-b", Algorithm: "ES256", Use: "sig"}
var published atomic.Value
published.Store([]jose.JSONWebKey{jwkA, jwkB})
var fetches atomic.Int32
ln, _ := net.Listen("tcp", "127.0.0.1:8092")
go http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fetches.Add(1)
json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: published.Load().([]jose.JSONWebKey)})
}))
sign := func(key *ecdsa.PrivateKey, kid string) string {
signer, _ := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: key},
(&jose.SignerOptions{}).WithHeader("kid", kid))
claims, _ := json.Marshal(map[string]any{
"iss": "https://id.example.com", "aud": "app", "sub": "user-42",
"exp": time.Now().Add(10 * time.Minute).Unix(),
})
jws, _ := signer.Sign(claims)
out, _ := jws.CompactSerialize()
return out
}
tokenA := sign(keyA, "2026-10-a")
ctx := context.Background()
keySet := oidc.NewRemoteKeySet(ctx, "http://127.0.0.1:8092/jwks.json")
verifier := oidc.NewVerifier("https://id.example.com", keySet, &oidc.Config{
ClientID: "app", SupportedSigningAlgs: []string{"ES256"},
})
start := time.Now()
check := func(label string) {
before := fetches.Load()
_, err := verifier.Verify(ctx, tokenA)
result := "accepted"
if err != nil {
result = err.Error()
}
fmt.Printf("t=%4.1fs %-34s %s (JWKS fetches +%d)\n", time.Since(start).Seconds(), label, result, fetches.Load()-before)
}
check("token A, set {A,B}")
published.Store([]jose.JSONWebKey{jwkB})
fmt.Printf("t=%4.1fs A removed from the set\n", time.Since(start).Seconds())
check("token A right after removal")
time.Sleep(5 * time.Second)
check("token A 5 s later")
// Any token with an unknown kid makes RemoteKeySet fetch the set again.
other, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
before := fetches.Load()
_, err := verifier.Verify(ctx, sign(other, "unknown-kid"))
fmt.Printf("t=%4.1fs %-34s %v (JWKS fetches +%d)\n", time.Since(start).Seconds(), "token with an unknown kid", err, fetches.Load()-before)
check("token A after that refetch")
}
t= 0.0s token A, set {A,B} accepted (JWKS fetches +1)
t= 0.0s A removed from the set
t= 0.0s token A right after removal accepted (JWKS fetches +0)
t= 5.0s token A 5 s later accepted (JWKS fetches +0)
t= 5.0s token with an unknown kid failed to verify signature: failed to verify id token signature (JWKS fetches +1)
t= 5.0s token A after that refetch failed to verify signature: failed to verify id token signature (JWKS fetches +1)
Der entfernte Schlüssel blieb vertrauenswürdig, bis etwas anderes einen Abruf auslöste. In einem ruhigen Dienst können das Tage sein. Außerdem ruft go-oidc bei jeder fehlgeschlagenen Prüfung ohne Rate Limit neu ab und fasst lediglich gleichzeitige Abrufe zu einem zusammen. Derselbe Fehler erscheint als failed to verify id token signature, egal ob die Ursache ein gefälschtes Token ist, ein noch nicht veröffentlichter Schlüssel oder ein bereits entfernter.
Weder jose noch jwks-rsa, PyJWT oder keyfunc liest den Header Cache-Control der JWKS-Antwort; ihr Quellcode verweist nirgends darauf. Es zählt die eigene Einstellung des Verifizierers, und die kannst du von der Seite des Ausstellers aus nicht ändern.
Cache-Control am JWKS-Endpunkt
Cache-Control spielt trotzdem eine Rolle: für CDNs und Reverse Proxies vor dem Endpunkt und für Verifizierer, die sich an die Empfehlung des Anbieters halten. Google etwa rät in seiner Dokumentation, „den Header Cache-Control in der Antwort auszuwerten, um zu bestimmen, wann du die Schlüssel erneut abrufen solltest“ (Google Identity). Das haben vier große Aussteller und ein Beispiel-Tenant am 20. Oktober 2026 gesendet, abgerufen mit diesem Skript:
#!/bin/sh
# Prints the caching headers and the number of keys of public JWKS endpoints.
for url in \
https://www.googleapis.com/oauth2/v3/certs \
https://login.microsoftonline.com/common/discovery/v2.0/keys \
https://appleid.apple.com/auth/keys \
https://token.actions.githubusercontent.com/.well-known/jwks \
https://samples.auth0.com/.well-known/jwks.json
do
echo "== $url"
curl -s -D - -o /tmp/body.json "$url" | grep -iE '^(cache-control|expires):' | tr -d '\r'
echo "keys: $(grep -o '"kid"' /tmp/body.json | wc -l)"
done
== https://www.googleapis.com/oauth2/v3/certs
expires: Tue, 20 Oct 2026 17:32:23 GMT
cache-control: public, max-age=21671, must-revalidate, no-transform
keys: 2
== https://login.microsoftonline.com/common/discovery/v2.0/keys
Cache-Control: max-age=86400, private
keys: 7
== https://appleid.apple.com/auth/keys
Cache-Control: no-store
keys: 3
== https://token.actions.githubusercontent.com/.well-known/jwks
cache-control: public, max-age=3600, must-revalidate
keys: 4
== https://samples.auth0.com/.well-known/jwks.json
cache-control: public, max-age=15, stale-while-revalidate=15, stale-if-error=86400
keys: 2
Das Skript gibt nur die Header Cache-Control und Expires aus und zählt die Felder kid im Body. Die Spanne reicht von „nie speichern“ (Apple) bis zu einem Tag (Microsoft), und jeder Aussteller hatte mehr als einen Schlüssel veröffentlicht – so sieht ein Schlüsselsatz aus, wenn Rotation Routine ist. Die Antworten unterscheiden sich außerdem von Anfrage zu Anfrage: Eine halbe Stunde vorher listete der Endpunkt von Microsoft acht Schlüssel, und das max-age von Google betrug 22.930 Sekunden. Ein Verifizierer, der max-age befolgt, bekommt also bei jedem Abruf eine andere Cache-Lebensdauer.
Für deinen eigenen Endpunkt:
- Leite
max-ageaus deinem Rotationsfenster ab, nicht umgekehrt. Was auch immer du sendest: Ein gemeinsam genutzter Cache hält den Schlüsselsatz so lange vor, und ein Neuabruf wegen eines unbekannten kid, der durch diesen Cache läuft, bekommt die veraltete Kopie. Einmax-agevon einer Stunde bedeutet, dass Schritt 2 oben mindestens eine Stunde warten muss. - Ergänze
stale-if-error. Fällt dein Endpunkt aus, verwenden Verifizierer und CDNs, die das unterstützen (RFC 5861), weiter den letzten funktionierenden Schlüsselsatz, statt jede Anfrage scheitern zu lassen. Der Beispiel-Tenant von Auth0 macht genau das. - Lass den Endpunkt nie einen leeren Schlüsselsatz oder eine Fehlerseite mit Status 200 zurückgeben. jwks-rsa scheitert dann mit The JWKS endpoint did not contain any signing keys, und jose bei einer Antwort mit einem anderen Status als 200 mit Expected 200 OK from the JSON Web Key Set HTTP response.
Entfernen im Notfall
Gelangt ein Signaturschlüssel nach außen, kehrt sich die Priorität um: Das Ziel sind nicht mehr null fehlgeschlagene Anfragen, sondern die kürzestmögliche Zeit, in der gefälschte Tokens akzeptiert werden. Die Reihenfolge:
- Veröffentliche einen Schlüsselsatz ohne den kompromittierten Schlüssel, in dem bereits ein neuer Schlüssel steht, und stelle das Signieren sofort auf den neuen Schlüssel um. Mit dem neuen Schlüssel signierte Tokens werden von manchen Verifizierern abgelehnt, bis deren Neuabruf wegen des unbekannten kid läuft; das ist der Preis.
- Zwinge die Verifizierer zum Neuabruf. Rufe die Methode für den erzwungenen Neuabruf aus der Tabelle oben auf oder starte die Prozesse neu. Bei go-oidc ist ein Neustart der einzige verlässliche Weg. Erreichst du nicht alle Verifizierer, ist deren Cache-Lebensdauer dein Risikofenster: 12 Stunden mit den Standardwerten von ASP.NET Core, unbegrenzt bei go-oidc.
- Leere jeden Cache vor dem JWKS-Endpunkt. Ein CDN, das noch den alten Schlüsselsatz ausliefert, macht Schritt 2 zunichte.
- Behandle alles, was der Schlüssel signiert hat, als verdächtig. Widerrufe Refresh Tokens und Sitzungen, die während des Risikofensters ausgestellt wurden, und suche in den Logs nach Tokens mit dem kompromittierten
kid, die nicht von deinem Aussteller stammen.
Übe das. Eine Notfallrotation ist eine geplante Rotation ohne die Wartezeiten; wenn geplante Rotationen automatisiert sind und alle paar Wochen laufen, ist der Notfallweg ein bekanntes Skript statt einer Improvisation. NIST SP 800-57 Part 1 Rev. 5 §5.3.6 schlägt für einen privaten Signaturschlüssel eine maximale Kryptoperiode von „etwa einem bis drei Jahren“ vor; das ist eine Obergrenze, kein Zielwert.
Checkliste
- Jeder Schlüssel im Schlüsselsatz hat einen eindeutigen
kid, einen Wert füralgund"use": "sig"; keine privaten Felder, keine symmetrischen Schlüssel. - Tokens tragen immer einen
kid. - Neue Schlüssel sind mindestens C lang veröffentlicht, bevor sie signieren; alte Schlüssel bleiben nach der letzten Signatur mindestens L + S + C lang veröffentlicht.
- Der JWKS-Endpunkt sendet ein bewusst gewähltes
Cache-Controlmitstale-if-error, gibt nie einen leeren Schlüsselsatz mit 200 zurück, und seine URL ändert sich nie. - Jeder Verifizierer, den du betreibst, hat eine bekannte Cache-Lebensdauer und einen bekannten Weg, einen Neuabruf zu erzwingen.
- Das Notfallverfahren ist aufgeschrieben und wurde schon einmal durchgespielt.
Quellen
- OpenID Connect Core 1.0 §10.1.1: Rotation asymmetrischer Signaturschlüssel
- RFC 7517: JSON Web Key, §4.5 (
kid) und §5 (JWK Set) - RFC 7638: JWK-Thumbprint
- RFC 8725: Best Current Practices für JWT
- RFC 5861:
stale-while-revalidateundstale-if-error - Microsoft Identity Platform: Rollover von Signaturschlüsseln
- Google Identity: Google-ID-Token prüfen
- NIST SP 800-57 Part 1 Rev. 5, §5.3.6