Saltar al contenido
ecdsa.com
← Todos los artículos

Rotación de claves JWKS sin interrupciones

La rotación falla de dos maneras: los verificadores rechazan tokens firmados con una clave que todavía no han descargado, o siguen aceptando una clave ya retirada. Ambas son cuestión de tiempos, y ambas se pueden medir. Aquí tienes la línea temporal, el comportamiento de caché de siete bibliotecas de verificación y lo que envían en realidad cuatro grandes proveedores.

Respuesta breve. Publica la nueva clave en tu JWKS antes de firmar nada con ella y espera al menos un tiempo de vida completo de la caché de los verificadores. Después, pasa a firmar con la nueva clave. Mantén publicada la clave antigua hasta que haya caducado el último token que firmó, más el desfase de reloj, más un tiempo de vida de la caché. Asigna a cada clave un kid que nunca se reutilice. En una emergencia, quita la clave y obliga a los verificadores a actualizar el conjunto: hasta que lo hagan, se sigue confiando en la clave eliminada, y esa ventana dura lo mismo que el tiempo de vida de la caché de tu verificador más lento.

Cómo encuentran la clave los verificadores

Un emisor publica sus claves públicas como conjunto de claves JWK (JWK Set) en el jwks_uri que figura en su documento de descubrimiento de OpenID Connect o en los metadatos de su servidor de autorización OAuth. Cada token lleva un kid en su encabezado. El verificador descarga el conjunto, lo guarda en caché y elige la clave cuyo kid coincide. OpenID Connect Core describe la rotación en cuatro frases (§10.1.1): el firmante añade claves nuevas al conjunto, empieza a usar una cuando quiere, señala el cambio mediante kid, y el verificador «sabe que debe volver a la ubicación de jwks_uri para obtener de nuevo las claves cuando ve un valor de kid que no conoce». El conjunto debería («SHOULD») «conservar durante un periodo de tiempo razonable las claves de firma retiradas recientemente».

Esta mecánica tiene dos consecuencias que la especificación deja implícitas:

  • Un kid desconocido provoca una descarga, pero los verificadores aplican un límite de frecuencia a esas descargas. Sin límite, cualquiera podría hacer que tu API descargara el JWKS en cada solicitud con solo enviar tokens con valores de kid aleatorios. Con límite, un token firmado por una clave que aún no estaba publicada la última vez que el verificador consultó el conjunto se rechaza hasta que vence el límite.
  • Un kid conocido nunca provoca una descarga. Quitar una clave del conjunto no tiene ningún efecto hasta que la caché del verificador caduca por sí sola. La lógica de actualizar cuando falta una clave no puede notar una eliminación, porque a la caché no le falta la clave eliminada.

La línea temporal, medida

El banco de pruebas siguiente ejecuta un emisor y dos verificadores en un solo proceso. El emisor sirve un JWKS por HTTP; los verificadores usan createRemoteJWKSet de jose con una caché de 4 segundos y un periodo de espera (cooldown) de 2 segundos entre actualizaciones por kid desconocido. Los despliegues reales usan minutos u horas; la forma de los resultados es la misma.

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();
Salida (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)

Pasaron tres cosas que también pasarán en producción:

  1. La rotación planificada funcionó sin un solo fallo, porque B ya estaba en la caché del verificador antes de que llegara el primer token firmado con B. No hizo falta ninguna actualización por kid desconocido.
  2. Firmar antes de publicar falló, y siguió fallando después de corregirlo. El verificador actualizó el conjunto ante el kid desconocido, no encontró C y después se negó a actualizarlo de nuevo durante el periodo de espera, aunque para entonces C ya estaba publicado. Con el periodo de espera predeterminado de jose, de 30 segundos, eso supone medio minuto de solicitudes rechazadas por cada instancia del verificador; con el predeterminado de keyfunc, de 5 minutos, son cinco minutos. El error que registra la API es no applicable key found in the JSON Web Key Set, idéntico al que provoca un token falsificado.
  3. Quitar una clave no la revocó. api2 siguió aceptando la clave eliminada A hasta que caducó su caché. Solo el verificador al que se le indicó llamar a reload() dejó de aceptarla al instante.

Reglas para una rotación sin interrupciones

Llamemos C al tiempo máximo durante el que cualquier verificador, o cualquier caché situada delante del endpoint JWKS, puede conservar el conjunto; L, a la vida útil más larga de un token que firmes; y S, al desfase de reloj que admiten los verificadores.

PasoCuándoQué
1. PublicarT₀Añade la nueva clave al conjunto, con un kid nuevo, su alg y "use": "sig". Sigue firmando con la clave antigua.
2. CambiarT₀ + CEmpieza a firmar con la nueva clave. Todos los verificadores han tenido ocasión de descargarla.
3. RetirarT₀ + C + L + SEl último token firmado con la clave antigua ha caducado en todas partes.
4. Eliminartras el paso 3, más C si quieres que las cachés la olvidenBorra la clave antigua del conjunto y destruye su mitad privada.

Un ejemplo práctico: un proveedor entre cuyos verificadores hay API de ASP.NET Core (metadatos actualizados cada 12 horas de forma predeterminada, véase más abajo), una caché de Nginx delante del JWKS con un tiempo de vida de una hora, tokens de acceso válidos durante una hora y 5 minutos de desfase. C es de 13 horas, así que la nueva clave entra en el conjunto el lunes por la mañana y empieza a firmar el lunes por la noche. La clave antigua puede salir del conjunto el martes por la mañana. La ventana es de un día, no de un minuto, y lo único que cuesta alargarla es una clave más en el conjunto.

Si los verificadores se configuran con una clave estática en lugar de la URL del JWKS, no hay ninguna C que esperar; la rotación exige un cambio de configuración coordinado en cada verificador, y por eso una URL de JWKS es la opción predeterminada en todas las bibliotecas de OpenID Connect.

Reglas para el kid

  • Único y nunca reutilizado. El RFC 7517 §4.5 pide («SHOULD») que las claves de un mismo conjunto «usen valores de “kid” distintos». Reutilizar un valor a lo largo del tiempo es peor que duplicarlo dentro de un conjunto: un verificador que guarda las claves en caché por kid conserva la clave antigua con ese nombre, y jose solo actualiza el conjunto cuando ninguna clave coincide, no cuando falla la verificación con una clave que sí coincide. Las firmas de la nueva clave fallan entonces hasta que caduca la caché.
  • Sin significado para los verificadores. Usa un valor aleatorio, una etiqueta de fecha como 2026-11-b o la huella digital JWK del RFC 7638. No codifiques en él nada en lo que los verificadores deban confiar; el kid llega en un encabezado sin verificar.
  • Siempre presente. Sin kid, un verificador tiene que probar todas las claves o rendirse. jwks-rsa se niega cuando el conjunto tiene más de una clave (No KID specified and JWKS endpoint returned more than 1 key), y jose falla con multiple matching keys found in the JSON Web Key Set. Por eso, un JWKS con dos claves y tokens sin kid se rompe justo durante cada rotación.
  • Acompañado de alg. Publica alg en cada clave para que los verificadores vinculen la clave a un único algoritmo, como exige el RFC 8725 §3.1.

Qué hacen en realidad las bibliotecas de verificación

La C de la tabla anterior la fijan los verificadores, no tú. Para verificadores escritos a mano, la guía de Microsoft sobre la sustitución de claves de firma es una buena referencia: guardar en caché cada clave por separado, según su kid, durante 24 horas, actualizar cada hora en segundo plano y actualizar ante un kid desconocido, pero no más de una vez cada 5 minutos. Los valores predeterminados de las bibliotecas que siguen proceden del código fuente de cada una o de un programa que imprime sus constantes, en las versiones indicadas:

BibliotecaTiempo de vida de la cachéActualización ante un kid desconocidoActualización forzada
jose 6.2.13 createRemoteJWKSet10 minutoscomo máximo una vez cada 30 sjwks.reload()
jwks-rsa 4.1.010 minutos, por kidcon cada kid no encontrado, salvo con rateLimit: true (entonces 10 por minuto)crear un cliente nuevo
PyJWT 2.15.1 PyJWKClient300 scomo máximo una vez cada 30 sget_jwk_set(refresh=True)
keyfunc v3.8.2 (Go)actualización en segundo plano cada horacomo máximo una vez cada 5 minutosStorage().KeyDelete(ctx, kid) elimina una clave
Nimbus JOSE+JWT 10.10, valores predeterminados de JWKSourceBuilder5 minutos, con actualización 30 s antes de caducarcomo máximo una vez cada 30 sconstruir una fuente nueva
Microsoft.IdentityModel 8.23.0 ConfigurationManager (ASP.NET Core)12 horascomo máximo una vez cada 5 minutosRequestRefresh(), a su vez limitado a una vez cada 5 minutos
go-oidc v3.21.0 RemoteKeySetsin caducidadcon cada kid no encontrado, con una sola descarga en cursoreiniciar

Spring Security 7.1.1 construye la fuente de su NimbusJwtDecoder con JWKSourceBuilder, pero desactiva la actualización anticipada y la limitación de frecuencia (.refreshAheadCache(false), .rateLimited(false) en JwkSetUriJwtDecoderBuilder), así que de esos valores predeterminados solo queda la caché de 5 minutos.

La última fila es la excepción. go-oidc conserva el conjunto de claves hasta que un token no supera la verificación con él; nada caduca por tiempo. Esto es lo que significa para una eliminación de emergencia:

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")
}
Salida (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 clave eliminada siguió siendo de confianza hasta que otra cosa provocó una descarga. En un servicio con poco tráfico, eso pueden ser días. Además, go-oidc vuelve a descargar el conjunto tras cada verificación fallida, sin límite de frecuencia, y lo único que hace es agrupar en una las descargas simultáneas. El mismo fallo aparece como failed to verify id token signature, tanto si la causa es un token falsificado como si es una clave aún no publicada o una ya eliminada.

Ni jose, ni jwks-rsa, ni PyJWT, ni keyfunc leen el encabezado Cache-Control de la respuesta del JWKS; su código fuente no hace ninguna referencia a él. Lo que cuenta es la configuración propia del verificador, y no puedes cambiarla desde el lado del emisor.

Cache-Control en el endpoint JWKS

Cache-Control sigue importando para las CDN y los proxies inversos situados delante del endpoint, y para los verificadores que siguen las recomendaciones del proveedor. Google, por ejemplo, indica a los desarrolladores que «examinen el encabezado Cache-Control de la respuesta para determinar cuándo deben volver a obtenerlas» (Google Identity). Esto es lo que enviaron cuatro grandes emisores y un inquilino de ejemplo el 20 de octubre de 2026, descargado con este 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: encabezados de caché y número de claves (curlimages/curl:8.11.1, 20 oct 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

El script solo imprime los encabezados Cache-Control y Expires y cuenta los miembros kid del cuerpo. El rango va de «no almacenar nunca» (Apple) a un día (Microsoft), y todos los emisores tenían más de una clave publicada, que es el aspecto que tiene un conjunto cuando la rotación es rutina. Las respuestas también varían de una solicitud a otra: media hora antes, el endpoint de Microsoft listaba ocho claves y el max-age de Google era de 22.930 segundos, así que un verificador que respeta max-age obtiene un tiempo de vida de caché distinto en cada descarga.

Para tu propio endpoint:

  • Elige max-age a partir de tu ventana de rotación, no al revés. Envíes lo que envíes, una caché compartida conservará el conjunto durante ese tiempo, y una actualización por kid desconocido que pase por esa caché recibirá la copia obsoleta. Un max-age de una hora significa que el paso 2 anterior debe esperar al menos una hora.
  • Añade stale-if-error. Si tu endpoint cae, los verificadores y las CDN que lo admiten (RFC 5861) siguen sirviendo el último conjunto correcto en lugar de hacer fallar todas las solicitudes. El inquilino de ejemplo de Auth0 hace exactamente eso.
  • Nunca dejes que el endpoint devuelva un conjunto vacío o una página de error con estado 200. En ese caso, jwks-rsa falla con The JWKS endpoint did not contain any signing keys, y jose, ante una respuesta con un estado distinto de 200, con Expected 200 OK from the JSON Web Key Set HTTP response.

Eliminación de emergencia

Cuando se filtra una clave de firma, la prioridad se invierte: el objetivo ya no es cero solicitudes fallidas, sino que el tiempo durante el que se aceptan tokens falsificados sea el más corto posible. El orden:

  1. Publica un conjunto sin la clave comprometida y que ya incluya una clave nueva, y pasa a firmar con la nueva clave de inmediato. Algunos verificadores rechazarán los tokens firmados con la nueva clave hasta que se ejecute su actualización por kid desconocido; ese es el precio.
  2. Obliga a los verificadores a actualizar el conjunto. Llama al método de actualización forzada de la tabla anterior o reinicia los procesos. Con go-oidc, reiniciar es la única forma fiable. Si no puedes llegar a todos los verificadores, el tiempo de vida de su caché es tu ventana de exposición: 12 horas con los valores predeterminados de ASP.NET Core, ilimitada con go-oidc.
  3. Purga todas las cachés situadas delante del endpoint JWKS. Una CDN que sigue sirviendo el conjunto antiguo deshace el paso 2.
  4. Trata como sospechoso todo lo que firmó la clave. Revoca los tokens de actualización y las sesiones emitidos durante la exposición, y busca en los registros tokens con el kid comprometido que tu emisor no haya generado.

Practícalo. Una rotación de emergencia es una rotación planificada sin las esperas; si las rotaciones planificadas están automatizadas y se ejecutan cada pocas semanas, el camino de emergencia es un script conocido y no una improvisación. El NIST SP 800-57 Part 1 Rev. 5 §5.3.6 sugiere un criptoperiodo máximo de «aproximadamente entre uno y tres años» para una clave privada de firma; es un techo, no un objetivo.

Lista de comprobación

  • Cada clave del conjunto tiene un kid único, un alg y "use": "sig"; ni miembros privados ni claves simétricas.
  • Los tokens siempre llevan un kid.
  • Las claves nuevas se publican al menos C antes de firmar; las antiguas permanecen al menos L + S + C después de la última firma.
  • El endpoint JWKS envía un Cache-Control elegido a propósito con stale-if-error, nunca devuelve un conjunto vacío con 200 y su URL nunca cambia.
  • Cada verificador que administras tiene un tiempo de vida de caché conocido y una forma conocida de forzar la actualización.
  • El procedimiento de emergencia está documentado y ensayado.

Fuentes