Aller au contenu
ecdsa.com
← Tous les articles

Rotation des clés JWKS sans interruption de service

La rotation échoue de deux façons : les vérificateurs rejettent des jetons signés avec une clé qu’ils n’ont pas encore récupérée, ou continuent d’accepter une clé que vous avez déjà retirée. Dans les deux cas, c’est une question de délais, et les deux se mesurent. Voici la chronologie, le comportement du cache de sept bibliothèques de vérification et ce que quatre grands fournisseurs envoient réellement.

En bref. Publiez la nouvelle clé dans votre JWKS avant de signer quoi que ce soit avec elle, et attendez au moins une durée de vie complète du cache des vérificateurs. Basculez ensuite la signature sur la nouvelle clé. Laissez l’ancienne clé publiée jusqu’à l’expiration du dernier jeton qu’elle a signé, plus le décalage d’horloge, plus une durée de vie du cache. Donnez à chaque clé un kid qui n’est jamais réutilisé. En cas d’urgence, retirez la clé et forcez les vérificateurs à se rafraîchir : tant qu’ils ne l’ont pas fait, la clé retirée reste considérée comme fiable, et c’est la durée de vie du cache de votre vérificateur le plus lent qui fixe la longueur de cette fenêtre.

Comment les vérificateurs trouvent la clé

Un émetteur publie ses clés publiques sous la forme d’un jeu de clés JWK (JWK Set) à l’adresse jwks_uri indiquée dans son document de découverte OpenID Connect ou dans les métadonnées de son serveur d’autorisation OAuth. Chaque jeton porte un kid dans son en-tête. Le vérificateur récupère le jeu, le met en cache et choisit la clé dont le kid correspond. OpenID Connect Core décrit la rotation en quatre phrases (§10.1.1) : le signataire ajoute de nouvelles clés au jeu, commence à utiliser l’une d’elles quand il le souhaite, signale le changement par le kid, et le vérificateur « sait qu’il doit retourner à l’emplacement jwks_uri pour récupérer à nouveau les clés lorsqu’il rencontre une valeur kid inconnue ». Le jeu « devrait (SHOULD) conserver les clés de signature récemment désaffectées pendant une durée raisonnable ».

Ce mécanisme a deux conséquences que la spécification laisse implicites :

  • Un kid inconnu déclenche une récupération, mais les vérificateurs appliquent une limite de requêtes à ces récupérations. Sans limite, n’importe qui pourrait obliger votre API à récupérer le JWKS à chaque requête en envoyant des jetons aux valeurs kid aléatoires. Avec une limite, un jeton signé par une clé qui n’était pas encore publiée lors du dernier passage du vérificateur est rejeté jusqu’à ce que la limite expire.
  • Un kid connu ne déclenche jamais de récupération. Retirer une clé du jeu n’a aucun effet tant que le cache du vérificateur n’a pas expiré de lui-même. La logique qui rafraîchit le cache quand une clé y est absente ne peut pas détecter un retrait, puisque la clé retirée n’est pas absente du cache.

La chronologie, mesurée

Le banc d’essai ci-dessous fait tourner un émetteur et deux vérificateurs dans un seul processus. L’émetteur sert un JWKS en HTTP ; les vérificateurs utilisent createRemoteJWKSet de jose avec un cache de 4 secondes et un délai de carence (cooldown) de 2 secondes entre deux rafraîchissements sur kid inconnu. Les déploiements réels comptent en minutes ou en heures ; le schéma reste le même.

rotation.mjs (jose 6.2.13, Node.js 24.22)
// 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();
Sortie (node:24.22.0-bookworm-slim)
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)

Il s’est passé trois choses qui se produiront aussi en production :

  1. La rotation planifiée a fonctionné sans aucun échec, parce que B était dans le cache du vérificateur avant l’arrivée du premier jeton signé avec B. Aucun rafraîchissement sur kid inconnu n’a été nécessaire.
  2. Signer avant de publier a échoué, et a continué d’échouer après la correction. Le vérificateur s’est rafraîchi sur le kid inconnu, n’a pas trouvé C, puis a refusé de se rafraîchir à nouveau pendant le délai de carence, alors même que C avait été publiée entre-temps. Avec le délai de carence par défaut de jose, 30 secondes, cela fait une demi-minute de requêtes rejetées par instance de vérificateur ; avec celui de keyfunc, 5 minutes par défaut, cela en fait cinq. L’erreur que l’API journalise est no applicable key found in the JSON Web Key Set, exactement la même que pour un jeton falsifié.
  3. Retirer une clé ne l’a pas révoquée. api2 a continué d’accepter la clé retirée A jusqu’à l’expiration de son cache. Seul le vérificateur auquel on a demandé reload() s’est arrêté immédiatement.

Règles pour une rotation sans interruption de service

Notons C la durée maximale pendant laquelle un vérificateur, ou un cache placé devant le point de terminaison JWKS, peut conserver le jeu ; L la durée de vie maximale d’un jeton que vous signez ; et S le décalage d’horloge que tolèrent les vérificateurs.

ÉtapeQuandQuoi
1. PublierT₀Ajoutez la nouvelle clé au jeu, avec un kid neuf, son alg et "use": "sig". Continuez à signer avec l’ancienne clé.
2. BasculerT₀ + CCommencez à signer avec la nouvelle clé. Chaque vérificateur a eu l’occasion de la récupérer.
3. DésaffecterT₀ + C + L + SLe dernier jeton signé avec l’ancienne clé a expiré partout.
4. Supprimeraprès l’étape 3, plus C si vous voulez que les caches l’oublientSupprimez l’ancienne clé du jeu et détruisez sa moitié privée.

Un exemple chiffré : un fournisseur dont les vérificateurs comprennent des API ASP.NET Core (métadonnées rafraîchies toutes les 12 heures par défaut, voir plus bas), un cache Nginx devant le JWKS avec une durée de vie d’une heure, des jetons d’accès valables une heure et 5 minutes de décalage. C vaut 13 heures : la nouvelle clé entre donc dans le jeu le lundi matin et commence à signer le lundi soir. L’ancienne clé peut partir le mardi matin. La fenêtre dure une journée, pas une minute, et l’allonger ne coûte qu’une clé de plus dans le jeu.

Si les vérificateurs sont configurés avec une clé statique au lieu de l’URL du JWKS, il n’y a pas de C à attendre ; la rotation exige alors une modification coordonnée de la configuration de chaque vérificateur, et c’est pourquoi une URL de JWKS est le choix par défaut de toutes les bibliothèques OpenID Connect.

Règles pour le kid

  • Unique et jamais réutilisé. La RFC 7517 §4.5 indique que les clés d’un même jeu « devraient (SHOULD) utiliser des valeurs “kid” distinctes ». La réutilisation dans le temps est pire qu’un doublon au sein d’un jeu : un vérificateur qui met les clés en cache par kid conserve l’ancienne clé sous ce nom, et jose ne se rafraîchit que lorsqu’aucune clé ne correspond, pas lorsque la vérification échoue avec une clé correspondante. Les signatures de la nouvelle clé échouent alors jusqu’à l’expiration du cache.
  • Sans signification pour les vérificateurs. Utilisez une valeur aléatoire, une étiquette datée comme 2026-11-b ou l’empreinte JWK de la RFC 7638. N’y encodez rien à quoi les vérificateurs devraient se fier ; le kid arrive dans un en-tête non vérifié.
  • Toujours présent. Sans kid, un vérificateur doit essayer toutes les clés ou abandonner. jwks-rsa s’y refuse quand le jeu contient plus d’une clé (No KID specified and JWKS endpoint returned more than 1 key), et jose échoue avec multiple matching keys found in the JSON Web Key Set. Un JWKS à deux clés combiné à des jetons sans kid casse donc précisément pendant chaque rotation.
  • Associé à alg. Publiez alg sur chaque clé afin que les vérificateurs lient la clé à un seul algorithme, comme l’exige la RFC 8725 §3.1.

Ce que font réellement les bibliothèques de vérification

Le C du tableau ci-dessus est fixé par les vérificateurs, pas par vous. Pour les vérificateurs écrits à la main, les recommandations de Microsoft sur la substitution des clés de signature constituent un bon choix par défaut : mettre les clés en cache individuellement par kid pendant 24 heures, rafraîchir toutes les heures en arrière-plan, et rafraîchir sur un kid inconnu, mais pas plus souvent que toutes les 5 minutes. Les valeurs par défaut des bibliothèques ci-dessous proviennent du code source de chaque bibliothèque ou d’un programme qui affiche ses constantes, dans les versions indiquées :

BibliothèqueDurée de vie du cacheRafraîchissement sur kid inconnuRafraîchissement forcé
jose 6.2.13 createRemoteJWKSet10 minutesau plus une fois toutes les 30 sjwks.reload()
jwks-rsa 4.1.010 minutes, par kidà chaque kid absent, sauf avec rateLimit: true (alors 10 par minute)créer un nouveau client
PyJWT 2.15.1 PyJWKClient300 sau plus une fois toutes les 30 sget_jwk_set(refresh=True)
keyfunc v3.8.2 (Go)rafraîchissement en arrière-plan toutes les heuresau plus une fois toutes les 5 minutesStorage().KeyDelete(ctx, kid) supprime une clé
Nimbus JOSE+JWT 10.10, valeurs par défaut de JWKSourceBuilder5 minutes, rafraîchi 30 s à l’avanceau plus une fois toutes les 30 sconstruire une nouvelle source
Microsoft.IdentityModel 8.23.0 ConfigurationManager (ASP.NET Core)12 heuresau plus une fois toutes les 5 minutesRequestRefresh(), lui-même limité à une fois toutes les 5 minutes
go-oidc v3.21.0 RemoteKeySetaucune expirationà chaque kid absent, une seule récupération en cours à la foisredémarrer

Spring Security 7.1.1 construit la source de son NimbusJwtDecoder avec JWKSourceBuilder, mais désactive le rafraîchissement anticipé et la limitation de débit (.refreshAheadCache(false), .rateLimited(false) dans JwkSetUriJwtDecoderBuilder) : de ces valeurs par défaut, il ne reste donc que le cache de 5 minutes.

La dernière ligne fait exception. go-oidc conserve le jeu de clés jusqu’à ce que la vérification d’un jeton échoue avec ce jeu ; rien n’expire au bout d’un délai. Voici ce que cela signifie pour un retrait d’urgence :

main.go (go-oidc v3.21.0, go-jose v4.1.6, Go 1.27.3)
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")
}
Sortie (golang:1.27.3-trixie)
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)

La clé retirée est restée considérée comme fiable jusqu’à ce qu’autre chose provoque une récupération. Dans un service peu sollicité, cela peut durer des jours. go-oidc récupère aussi le jeu à nouveau après chaque vérification échouée, sans limitation de débit, en se contentant de regrouper les récupérations simultanées en une seule. Le même échec apparaît sous la forme failed to verify id token signature, que la cause soit un jeton falsifié, une clé pas encore publiée ou une clé déjà retirée.

Ni jose, ni jwks-rsa, ni PyJWT, ni keyfunc ne lisent l’en-tête Cache-Control de la réponse JWKS ; leur code source n’y fait aucune référence. C’est le réglage propre au vérificateur qui compte, et vous ne pouvez pas le modifier côté émetteur.

Cache-Control sur le point de terminaison JWKS

Cache-Control reste utile, pour les CDN et les proxys inverses placés devant le point de terminaison, et pour les vérificateurs qui suivent les conseils du fournisseur. Google, par exemple, demande aux développeurs d’« examiner l’en-tête Cache-Control de la réponse pour déterminer quand les récupérer à nouveau » (Google Identity). Voici ce qu’envoyaient le 20 octobre 2026 quatre grands émetteurs et un tenant de démonstration, relevé avec ce script :

headers.sh
#!/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
headers.sh : en-têtes de cache et nombre de clés (curlimages/curl:8.11.1, 20 octobre 2026)
== 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

Le script n’affiche que les en-têtes Cache-Control et Expires, et compte les membres kid du corps. L’éventail va de « ne jamais stocker » (Apple) à une journée (Microsoft), et chaque émetteur avait plus d’une clé publiée, ce qui est précisément l’aspect d’un jeu quand la rotation est une routine. Les réponses varient aussi d’une requête à l’autre : une demi-heure plus tôt, le point de terminaison de Microsoft listait huit clés et le max-age de Google valait 22 930 secondes ; un vérificateur qui respecte max-age obtient donc une durée de vie du cache différente à chaque récupération.

Pour votre propre point de terminaison :

  • Choisissez max-age d’après votre fenêtre de rotation, et non l’inverse. Quelle que soit la valeur envoyée, un cache partagé conservera le jeu aussi longtemps, et un rafraîchissement sur kid inconnu qui passe par ce cache en reçoit la copie périmée. Un max-age d’une heure signifie que l’étape 2 ci-dessus doit attendre au moins une heure.
  • Ajoutez stale-if-error. Si votre point de terminaison est indisponible, les vérificateurs et les CDN qui le prennent en charge (RFC 5861) continuent de servir le dernier jeu valide au lieu de faire échouer chaque requête. Le tenant de démonstration d’Auth0 fait exactement cela.
  • Ne laissez jamais le point de terminaison renvoyer un jeu vide ou une page d’erreur avec le statut 200. jwks-rsa échoue alors avec The JWKS endpoint did not contain any signing keys, et jose avec Expected 200 OK from the JSON Web Key Set HTTP response sur une réponse autre que 200.

Retrait d’urgence

Quand une clé de signature fuit, la priorité s’inverse : l’objectif n’est plus zéro requête en échec, mais la durée la plus courte possible pendant laquelle des jetons falsifiés sont acceptés. L’ordre :

  1. Publiez un jeu sans la clé compromise, qui contient déjà une nouvelle clé, et basculez immédiatement la signature sur cette nouvelle clé. Les jetons signés par la nouvelle clé seront rejetés par certains vérificateurs jusqu’à ce que leur rafraîchissement sur kid inconnu s’exécute ; c’est le prix à payer.
  2. Forcez les vérificateurs à se rafraîchir. Appelez la méthode de rafraîchissement forcé indiquée dans le tableau ci-dessus, ou redémarrez les processus. Pour go-oidc, le redémarrage est le seul moyen fiable. Si vous ne pouvez pas atteindre tous les vérificateurs, la durée de vie de leur cache est votre fenêtre d’exposition : 12 heures avec les valeurs par défaut d’ASP.NET Core, illimitée avec go-oidc.
  3. Purgez tous les caches placés devant le point de terminaison JWKS. Un CDN qui sert encore l’ancien jeu annule l’étape 2.
  4. Considérez comme suspect tout ce que la clé a signé. Révoquez les jetons d’actualisation et les sessions émis pendant l’exposition, et cherchez dans les journaux des jetons portant le kid compromis que votre émetteur n’a pas générés.

Exercez-vous à cette procédure. Une rotation d’urgence est une rotation planifiée sans les temps d’attente ; si les rotations planifiées sont automatisées et ont lieu à quelques semaines d’intervalle, la procédure d’urgence est un script connu plutôt qu’une improvisation. La NIST SP 800-57 Part 1 Rev. 5 §5.3.6 propose une cryptopériode maximale d’« environ un à trois ans » pour une clé privée de signature ; c’est un plafond, pas un objectif.

Liste de contrôle

  • Chaque clé du jeu a un kid unique, un alg et "use": "sig" ; aucun membre privé, aucune clé symétrique.
  • Les jetons portent toujours un kid.
  • Les nouvelles clés sont publiées au moins C avant de signer ; les anciennes clés restent au moins L + S + C après la dernière signature.
  • Le point de terminaison JWKS envoie un Cache-Control choisi délibérément, avec stale-if-error, ne renvoie jamais un jeu vide avec le statut 200, et son URL ne change jamais.
  • Chaque vérificateur que vous exploitez a une durée de vie du cache connue et un moyen connu de forcer un rafraîchissement.
  • La procédure d’urgence est écrite et a déjà été exécutée.

Sources