Pular para o conteúdo
ecdsa.com
← Todos os artigos

Rotação de chaves JWKS sem interrupção

A rotação falha de duas maneiras: os verificadores rejeitam tokens assinados com uma chave que ainda não buscaram, ou continuam aceitando uma chave que você já retirou. Os dois são problemas de tempo, e os dois podem ser medidos. Veja a linha do tempo, o comportamento de cache de sete bibliotecas de verificação e o que quatro grandes provedores enviam de fato.

Resposta curta. Publique a nova chave no seu JWKS antes de assinar qualquer coisa com ela e espere pelo menos um tempo de vida completo do cache dos verificadores. Depois, passe a assinar com a nova chave. Mantenha a chave antiga publicada até que o último token assinado por ela tenha expirado, mais o desvio de relógio, mais um tempo de vida do cache. Dê a cada chave um kid que nunca seja reutilizado. Em uma emergência, remova a chave e faça os verificadores atualizarem o conjunto: até que isso aconteça, a chave removida continua sendo aceita como confiável, e o tempo de vida do cache do seu verificador mais lento é a duração dessa janela.

Como os verificadores encontram a chave

Um emissor publica suas chaves públicas como um conjunto de chaves JWK (JWK Set) no jwks_uri indicado no documento de descoberta do OpenID Connect ou nos metadados do servidor de autorização OAuth. Todo token traz um kid no cabeçalho. O verificador busca o conjunto, mantém uma cópia em cache e escolhe a chave cujo kid corresponde ao do token. O OpenID Connect Core descreve a rotação em quatro frases (§10.1.1): o signatário adiciona novas chaves ao conjunto, começa a usar uma delas quando quiser, sinaliza a mudança pelo kid, e o verificador “sabe que deve voltar ao local do jwks_uri para buscar as chaves de novo quando vê um valor de kid desconhecido”. O conjunto “DEVERIA manter as chaves de assinatura desativadas recentemente por um período de tempo razoável”.

Esse mecanismo tem duas consequências que a especificação deixa implícitas:

  • Um kid desconhecido dispara uma busca, mas os verificadores limitam a taxa dessas buscas. Sem um limite, qualquer pessoa poderia fazer sua API buscar o JWKS a cada requisição, bastando enviar tokens com valores de kid aleatórios. Com um limite, um token assinado por uma chave que ainda não estava publicada na última consulta do verificador é rejeitado até que o limite expire.
  • Um kid conhecido nunca dispara uma busca. Remover uma chave do conjunto não tem efeito nenhum até que o cache do verificador expire por conta própria. A lógica de atualizar quando a chave não é encontrada não consegue perceber uma remoção, porque a chave removida não está faltando no cache.

A linha do tempo, medida

O script de teste abaixo roda um emissor e dois verificadores em um único processo. O emissor serve um JWKS via HTTP; os verificadores usam o createRemoteJWKSet do jose com um cache de 4 segundos e um intervalo de espera (cooldown) de 2 segundos entre as atualizações por kid desconhecido. Implantações reais usam minutos ou horas; o padrão é o mesmo.

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();
Saída (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)

Aconteceram três coisas que também vão acontecer em produção:

  1. A rotação planejada funcionou sem nenhuma falha, porque B já estava no cache do verificador antes de chegar o primeiro token assinado com B. Nenhuma atualização por kid desconhecido foi necessária.
  2. Assinar antes de publicar falhou, e continuou falhando depois da correção. O verificador atualizou o conjunto ao ver o kid desconhecido, não encontrou C e depois se recusou a atualizar de novo durante o intervalo de espera, embora C já estivesse publicado. Com o intervalo de espera padrão do jose, de 30 segundos, isso dá meio minuto de requisições rejeitadas por instância de verificador; com o padrão do keyfunc, de 5 minutos, são cinco minutos. O erro que a API registra é no applicable key found in the JSON Web Key Set, exatamente o mesmo que um token forjado provoca.
  3. Remover uma chave não a revogou. O api2 continuou aceitando a chave A, já removida, até o cache dele expirar. Só o verificador em que reload() foi chamado parou na hora.

Regras para uma rotação sem interrupção

Seja C o maior tempo durante o qual qualquer verificador, ou qualquer cache na frente do endpoint JWKS, pode manter o conjunto; L, o maior tempo de vida de um token que você assina; e S, o desvio de relógio que os verificadores toleram.

EtapaQuandoO quê
1. PublicarT₀Adicione a nova chave ao conjunto, com um kid novo, o alg dela e "use": "sig". Continue assinando com a chave antiga.
2. TrocarT₀ + CComece a assinar com a nova chave. Todos os verificadores já tiveram a chance de buscá-la.
3. AposentarT₀ + C + L + SO último token assinado com a chave antiga já expirou em todos os lugares.
4. Removerdepois da etapa 3, mais C se você quiser que os caches a esqueçamExclua a chave antiga do conjunto e destrua a metade privada dela.

Um exemplo prático: um provedor cujos verificadores incluem APIs ASP.NET Core (metadados atualizados a cada 12 horas por padrão, veja abaixo), um cache Nginx na frente do JWKS com tempo de vida de uma hora, tokens de acesso válidos por uma hora e 5 minutos de desvio de relógio. C é de 13 horas, então a nova chave entra no conjunto na segunda-feira de manhã e passa a ser usada para assinar na segunda à noite. A chave antiga pode sair na terça de manhã. A janela é de um dia, não de um minuto, e o único custo de deixá-la mais longa é uma chave a mais no conjunto.

Se os verificadores forem configurados com uma chave estática em vez da URL do JWKS, não há C a esperar; a rotação exige uma mudança de configuração coordenada em todos os verificadores, e é por isso que a URL do JWKS é o padrão em toda biblioteca de OpenID Connect.

Regras para o kid

  • Único e nunca reutilizado. A RFC 7517 §4.5 diz que as chaves de um conjunto “DEVERIAM usar valores ‘kid’ distintos”. Reutilizar ao longo do tempo é pior do que duplicar dentro de um conjunto: um verificador que faz cache por kid mantém a chave antiga sob esse nome, e o jose só atualiza o conjunto quando nenhuma chave corresponde, não quando uma chave correspondente falha na verificação. As assinaturas da nova chave passam então a falhar até o cache expirar.
  • Sem significado para os verificadores. Use um valor aleatório, um rótulo de data como 2026-11-b ou a impressão digital (thumbprint) da JWK definida na RFC 7638. Não codifique nada em que os verificadores devam confiar; o kid chega em um cabeçalho não verificado.
  • Sempre presente. Sem um kid, o verificador precisa testar todas as chaves ou desistir. O jwks-rsa recusa o token quando o conjunto tem mais de uma chave (No KID specified and JWKS endpoint returned more than 1 key), e o jose falha com multiple matching keys found in the JSON Web Key Set. Por isso, um JWKS com duas chaves e tokens sem kid quebra justamente durante cada rotação.
  • Acompanhado de alg. Publique o alg em cada chave para que os verificadores vinculem a chave a um único algoritmo, como exige a RFC 8725 §3.1.

O que as bibliotecas de verificação fazem de fato

O C da tabela acima é definido pelos verificadores, não por você. Para verificadores escritos à mão, as orientações da Microsoft sobre a substituição de chaves de assinatura são um bom padrão: mantenha as chaves em cache individualmente por kid durante 24 horas, atualize a cada hora em segundo plano e atualize ao receber um kid desconhecido, mas não mais do que uma vez a cada 5 minutos. Os padrões das bibliotecas abaixo vêm do código-fonte de cada uma ou de um programa que imprime suas constantes, nas versões listadas:

BibliotecaTempo de vida do cacheAtualização por kid desconhecidoAtualização forçada
jose 6.2.13 createRemoteJWKSet10 minutosno máximo uma vez a cada 30 sjwks.reload()
jwks-rsa 4.1.010 minutos, por kida cada kid não encontrado, exceto com rateLimit: true (nesse caso, 10 por minuto)criar um novo cliente
PyJWT 2.15.1 PyJWKClient300 sno máximo uma vez a cada 30 sget_jwk_set(refresh=True)
keyfunc v3.8.2 (Go)atualização em segundo plano a cada horano máximo uma vez a cada 5 minutosStorage().KeyDelete(ctx, kid) remove uma chave
Nimbus JOSE+JWT 10.10, padrões do JWKSourceBuilder5 minutos, atualizado com 30 s de antecedênciano máximo uma vez a cada 30 scriar uma nova fonte
Microsoft.IdentityModel 8.23.0 ConfigurationManager (ASP.NET Core)12 horasno máximo uma vez a cada 5 minutosRequestRefresh(), que também é limitado a uma vez a cada 5 minutos
go-oidc v3.21.0 RemoteKeySetsem expiraçãoa cada kid não encontrado, com uma busca em andamento por vezreiniciar

O Spring Security 7.1.1 monta a fonte do NimbusJwtDecoder com o JWKSourceBuilder, mas desliga a atualização antecipada e o limite de taxa (.refreshAheadCache(false), .rateLimited(false) no JwkSetUriJwtDecoderBuilder), então o cache de 5 minutos é tudo o que sobra desses padrões.

A última linha é a exceção. O go-oidc mantém o conjunto de chaves até que um token falhe na verificação contra ele; nada expira por tempo. Veja o que isso significa para uma remoção de emergência:

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")
}
Saída (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)

A chave removida continuou sendo aceita como confiável até que outra coisa provocasse uma busca. Em um serviço com pouco tráfego, isso pode levar dias. O go-oidc também busca o conjunto de novo a cada verificação com falha, sem limite de taxa, apenas juntando buscas simultâneas em uma só. A mesma falha aparece como failed to verify id token signature, seja a causa um token forjado, uma chave ainda não publicada ou uma chave já removida.

Nenhuma das bibliotecas jose, jwks-rsa, PyJWT e keyfunc lê o cabeçalho Cache-Control da resposta do JWKS; o código-fonte delas não faz nenhuma referência a ele. O que vale é a configuração do próprio verificador, e você não consegue alterá-la do lado do emissor.

Cache-Control no endpoint JWKS

O Cache-Control ainda é importante para CDNs e proxies reversos na frente do endpoint e para verificadores que seguem a recomendação do provedor. O Google, por exemplo, orienta os desenvolvedores a “examinar o cabeçalho Cache-Control na resposta para determinar quando buscá-las novamente” (Google Identity). Veja o que quatro grandes emissores e um locatário (tenant) de exemplo enviaram em 20 de outubro de 2026; os dados foram coletados com 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: cabeçalhos de cache e número de chaves (curlimages/curl:8.11.1, 20 de outubro de 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

O script imprime apenas os cabeçalhos Cache-Control e Expires e conta os membros kid no corpo. A faixa vai de “nunca armazenar” (Apple) a um dia (Microsoft), e todos os emissores tinham mais de uma chave publicada, que é como um conjunto fica quando a rotação é rotina. As respostas também variam entre requisições: meia hora antes, o endpoint da Microsoft listava oito chaves e o max-age do Google era de 22930 segundos, então um verificador que respeita o max-age recebe um tempo de vida de cache diferente a cada busca.

Para o seu próprio endpoint:

  • Escolha o max-age a partir da janela de rotação, e não o contrário. Seja qual for o valor enviado, um cache compartilhado vai manter o conjunto por esse tempo, e uma atualização por kid desconhecido que passa por esse cache recebe a cópia desatualizada. Um max-age de uma hora significa que a etapa 2 acima precisa esperar pelo menos uma hora.
  • Adicione stale-if-error. Se o seu endpoint ficar fora do ar, os verificadores e as CDNs que oferecem suporte a essa diretiva (RFC 5861) continuam servindo o último conjunto válido em vez de fazer todas as requisições falharem. O locatário de exemplo da Auth0 faz exatamente isso.
  • Nunca deixe o endpoint retornar um conjunto vazio ou uma página de erro com status 200. Nesse caso, o jwks-rsa falha com The JWKS endpoint did not contain any signing keys, e o jose, com Expected 200 OK from the JSON Web Key Set HTTP response quando a resposta não tem status 200.

Remoção de emergência

Quando uma chave de assinatura vaza, a prioridade se inverte: o objetivo deixa de ser zero requisições com falha e passa a ser o menor tempo possível durante o qual tokens forjados são aceitos. A ordem:

  1. Publique um conjunto sem a chave comprometida e que já contenha uma nova chave, e passe a assinar com a nova chave imediatamente. Alguns verificadores vão rejeitar os tokens assinados pela nova chave até que a atualização por kid desconhecido deles aconteça; esse é o preço.
  2. Force os verificadores a atualizar o conjunto. Chame o método de atualização forçada da tabela acima ou reinicie os processos. No go-oidc, reiniciar é a única forma confiável. Se você não conseguir alcançar todos os verificadores, o tempo de vida do cache deles é a sua janela de exposição: 12 horas com os padrões do ASP.NET Core, sem limite no go-oidc.
  3. Limpe todos os caches na frente do endpoint JWKS. Uma CDN que ainda serve o conjunto antigo desfaz a etapa 2.
  4. Trate como suspeito tudo o que a chave assinou. Revogue os tokens de atualização e as sessões emitidos durante a exposição e procure nos logs tokens com o kid comprometido que não foram gerados pelo seu emissor.

Pratique esse procedimento. Uma rotação de emergência é uma rotação planejada sem a espera; se as rotações planejadas são automatizadas e rodam a cada poucas semanas, o caminho de emergência é um script conhecido, e não um improviso. O NIST SP 800-57 Part 1 Rev. 5 §5.3.6 sugere um criptoperíodo máximo de “cerca de um a três anos” para uma chave privada de assinatura; isso é um teto, não uma meta.

Lista de verificação

  • Cada chave do conjunto tem um kid único, um alg e "use": "sig"; nenhum membro privado, nenhuma chave simétrica.
  • Os tokens sempre trazem um kid.
  • As novas chaves são publicadas pelo menos C antes de assinar; as chaves antigas ficam pelo menos L + S + C depois da última assinatura.
  • O endpoint JWKS envia um Cache-Control definido de propósito, com stale-if-error, nunca retorna um conjunto vazio com status 200, e a URL dele nunca muda.
  • Todo verificador que você opera tem um tempo de vida de cache conhecido e uma forma conhecida de forçar a atualização.
  • O procedimento de emergência está documentado e já foi executado.

Fontes