Verificar um JWT não é uma checagem, mas seis, e a assinatura é só uma delas. Em resumo: o verificador, e não o token, decide como o token é verificado. Fixe o algoritmo no servidor, procure a chave entre as chaves em que você já confia para aquele emissor, exija exp, compare iss e aud de forma exata, confira typ para que um tipo de token não possa se passar por outro, admita no máximo um minuto de desvio de relógio e mantenha o JWKS em cache, com uma atualização sob limite de taxa para valores de kid desconhecidos. Tudo o que vem a seguir é o que dá errado quando falta um desses itens, medido em bibliotecas reais.
Toda a saída desta página vem de tokens emitidos para este artigo com uma chave ES256 recém-gerada e verificados pelo jose 6.2.13 e pelo jsonwebtoken 9.0.3 (com o jwks-rsa 4.1.0) no Node.js 24.22, pelo PyJWT 2.15.1 no Python 3.14.8 e pelo golang-jwt 5.3.1 com o keyfunc 3.8.2 no Go 1.27.3, cada um em sua imagem Docker oficial.
O que uma biblioteca verifica quando você passa só a chave
A maioria dos bugs de JWT não está na biblioteca. São chamadas que verificam a assinatura e param por aí, porque quem chamou nunca disse à biblioteca qual emissor, público-alvo ou algoritmo esperar. Veja o que cada biblioteca fez com três tokens quando chamada com uma chave pública válida e nenhuma outra opção (o PyJWT se recusa a rodar sem algorithms, então recebeu ["ES256"]):
| Token | jose jwtVerify | jsonwebtoken verify | PyJWT decode | golang-jwt Parse |
|---|---|---|---|---|
Assinado pela chave certa, mas com iss e aud de outro serviço | aceito | aceito | rejeitado: Invalid audience | aceito |
Assinado pela chave certa, sem a claim exp | aceito | aceito | aceito | aceito |
alg: "none", assinatura vazia | rejeitado | rejeitado | rejeitado | rejeitado |
Duas lições. Primeiro, as quatro bibliotecas recusam tokens não assinados por padrão, então o famoso problema do none hoje vive sobretudo em verificadores escritos à mão. Segundo, um token sem exp é válido para sempre nas quatro, e três das quatro aceitam um token emitido para outro serviço, desde que a mesma chave o tenha assinado. exp, nbf, iss e aud são claims opcionais na RFC 7519 §4.1; as bibliotecas as verificam quando estão presentes e quando foram configuradas, e o resto fica por sua conta.
Erro 1: deixar o token escolher o algoritmo
O cabeçalho alg chega dentro do token que você ainda não verificou. Um verificador que o lê e decide o caminho com base nele deixa o remetente escolher as regras. A RFC 8725 §3.1, que reúne as boas práticas atuais (BCP) para JWT, é categórica: as bibliotecas devem permitir que quem chama especifique os algoritmos aceitos e não devem usar nenhum outro, e cada chave deve ser usada com exatamente um algoritmo.
A falha clássica é a confusão HS/RS (ou HS/ES), descrita na RFC 8725 §2.1 e registrada para o jsonwebtoken como CVE-2015-9235. Um serviço tem uma chave pública e verifica “o que o cabeçalho disser”. Alguém calcula um HMAC sobre um cabeçalho com "alg": "HS256" usando o texto PEM da chave pública como segredo. A chave pública é pública, então qualquer pessoa consegue fazer isso. Veja um verificador escrito à mão exatamente com esse formato:
import { createHmac, timingSafeEqual, verify } from "node:crypto";
import { readFileSync } from "node:fs";
// Anti-pattern: the token picks the algorithm, one key object serves both families.
function naiveVerify(token, publicKeyPem) {
const [h, p, s] = token.split(".");
const { alg } = JSON.parse(Buffer.from(h, "base64url"));
const input = Buffer.from(`${h}.${p}`);
const sig = Buffer.from(s, "base64url");
if (alg === "HS256") {
const mac = createHmac("sha256", publicKeyPem).update(input).digest();
return mac.length === sig.length && timingSafeEqual(mac, sig);
}
if (alg === "ES256") {
return verify("sha256", input, { key: publicKeyPem, dsaEncoding: "ieee-p1363" }, sig);
}
throw new Error(`unsupported alg ${alg}`);
}
const pem = readFileSync("out/public.pem", "utf8");
const tokens = JSON.parse(readFileSync("out/tokens.json", "utf8"));
console.log("ES256 token from the issuer:", naiveVerify(tokens.ok, pem));
console.log("HS256 token keyed with the public PEM:", naiveVerify(tokens["HS256 keyed with the public PEM"], pem));
ES256 token from the issuer: true
HS256 token keyed with the public PEM: true
As duas chamadas retornam true. O segundo token foi criado sem a chave privada. Observe que a comparação em tempo constante não ajuda: o código toma cuidado com o tempo de execução e mesmo assim erra na questão da confiança.
A correção é uma lista de permissões fixa do lado do verificador, de preferência com um único valor, e chaves que carregam o próprio algoritmo. As quatro bibliotecas rejeitaram o token forjado quando receberam algorithms: ["ES256"], cada uma com sua própria mensagem: jose "alg" (Algorithm) Header Parameter value not allowed, jsonwebtoken invalid algorithm, PyJWT The specified alg value is not allowed, golang-jwt signing method HS256 is invalid. Elas também têm uma segunda linha de defesa, e é por isso que essa confusão hoje é rara em código de biblioteca:
- jsonwebtoken 9.x (desde a 9.0.0, de dezembro de 2022) deriva os algoritmos permitidos do tipo da chave quando você omite
algorithms: uma chave EC permite apenasES256,ES384eES512. - PyJWT se recusa a usar uma chave pública ou um certificado PEM como segredo HMAC: The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret.
- golang-jwt verifica o tipo Go da chave: o método HMAC espera
[]byte, então um*ecdsa.PublicKeyfalha com key is of invalid type. - jose verifica se o tipo da chave combina com o algoritmo. Mesmo sem a opção
algorithms, o token forjado, verificado com a chave pública EC, falhou comKeyObject instances must be of type "secret" for the HS256 algorithm.
Nenhuma dessas defesas substitui a lista de permissões. Elas protegem o caso comum, não um carregador de chaves personalizado que entrega bytes brutos à biblioteca.
Erro 2: aceitar “none” em código escrito à mão
A RFC 7518 §3.6 registra "alg": "none" para tokens cuja integridade é protegida por outro meio. Na tabela acima, todas as bibliotecas o rejeitaram por padrão. O risco está no código ao redor da biblioteca: um “modo de depuração” que pula a verificação, uma condição que trata uma assinatura vazia como “ainda não assinado” ou uma lista de bloqueio que compara alg diferenciando maiúsculas de minúsculas. A revisão dessa BCP, o draft-ietf-oauth-rfc8725bis (na fila do RFC Editor em outubro de 2026), aponta exatamente isso: uma lista de bloqueio para none que um cabeçalho com noNE consegue driblar. Uma lista de permissões não tem esse furo.
Erro 3: confiar em kid, jku, x5u ou em uma jwk embutida
O kid existe para escolher uma entre várias chaves em que você já confia. Ele não é um ponteiro para uma chave fornecida pelo token. A RFC 8725 §3.10 manda tratar os cabeçalhos recebidos como entrada não confiável:
- Procure o
kidem um mapa, não em uma consulta ou em um caminho. Umkidque chega a SQL, LDAP ou a um nome de arquivo é um ponto de injeção. As bibliotecas de JWKS deste artigo apenas o comparam com os valores dekiddas chaves que buscaram na URL que você configurou, e esse é o formato certo. - Ignore
jkuex5u. As duas são URLs dentro do token. Buscá-las deixa o remetente escolher a que lugar seu servidor se conecta e quais chaves ele usa. Em vez disso, configure a URL do JWKS por emissor; a RFC 8725 sugere comparar essas URLs com uma lista de permissões, se você realmente precisar usá-las. - Nunca verifique um token de acesso com uma
jwkou umx5cembutido no próprio cabeçalho dele. Um token assinado com uma chave que ele mesmo carrega prova apenas que o remetente tem alguma chave. Os protocolos que de fato usam uma chave embutida, como as provas DPoP, a vinculam a outra coisa (a claimcnfdo token de acesso). Veja DPoP explicado. - Trate a ausência de
kidde forma explícita. O jwks-rsa se recusa a adivinhar quando o conjunto tem várias chaves e o token não indica nenhuma (No KID specified and JWKS endpoint returned more than 1 key); uma busca escrita à mão que retorna “a primeira chave” vai escolher a chave errada, sem aviso, depois de uma rotação.
Erro 4: não verificar aud e iss, ou verificá-los sem rigor
Um provedor de identidade costuma assinar tokens para muitos clientes e APIs com a mesma chave. Se sua API não verifica aud, um token emitido para qualquer outra aplicação do mesmo provedor funciona na sua, e esse é o caso da primeira linha da tabela. A RFC 8725 §3.9 exige que o destinatário rejeite um JWT cujo público-alvo esteja ausente ou não seja ele mesmo, e a RFC 9068 §4 exige correspondência exata de iss para tokens de acesso OAuth.
Três detalhes importam na prática:
- Comparação exata de strings.
audeisssão strings que diferenciam maiúsculas de minúsculas, ehttps://id.example.comnão éhttps://id.example.com/. Copie o emissor do documento de descoberta do provedor, não de um token que você tenha à mão. audpode ser um array. As bibliotecas aceitam o token se qualquer elemento corresponder. Isso está correto segundo a RFC 7519, mas significa que um token para["https://api.example.com", "https://admin.example.com"]funciona nas duas APIs. Se sua API só deve aceitar tokens emitidos exclusivamente para ela, o PyJWT temoptions={"strict_aud": True}; nas outras bibliotecas, verifique você mesmo o tamanho do array.- As chaves devem pertencer ao emissor. RFC 8725 §3.8: se um token tem um
iss, a chave que o verificou deve ser a chave desse emissor. Com vários emissores, mantenha um cliente JWKS por emissor e escolha-o pela configuração, nunca apenas peloissnão verificado do token. Em provedores multilocatários que compartilham um conjunto de chaves entre locatários (tenants) — o Microsoft Entra ID é o caso comum —, a verificação deissé a única coisa que separa seu locatário dos de todos os outros; o artigo validação do token de ID do OIDC trata disso em detalhes.
Erro 5: tokens sem exp e tolerância generosa
Uma biblioteca verifica a claim exp quando ela está presente. Quando ela falta, as quatro aceitaram o token. Se seu emissor um dia esquecer a claim, ou se alguém encontrar um caminho de código que emite tokens sem ela, esses tokens nunca expiram. Torne exp obrigatória: requiredClaims no jose, options={"require": [...]} no PyJWT, jwt.WithExpirationRequired() no golang-jwt e uma verificação explícita no jsonwebtoken, que não tem uma opção assim.
O desvio de relógio existe, mas é pequeno. A RFC 7519 §4.1.4 permite “alguma pequena tolerância, geralmente não mais do que alguns minutos”. Trinta a sessenta segundos bastam para servidores mantidos em sincronia por NTP. Uma tolerância de uma hora, muitas vezes adicionada para fazer passar um teste instável, estende em uma hora a vida útil de todo token, inclusive dos tokens que você queria que deixassem de valer. A opção é clockTolerance no jose e no jsonwebtoken (em segundos), leeway no PyJWT e jwt.WithLeeway no golang-jwt. A mesma tolerância vale para nbf.
Quando uma verificação falha com um erro de expiração ou de “ainda não válido” em um token que “deveria” ser válido, compare o relógio do servidor com o do emissor antes de mexer na tolerância. Um contêiner ou uma VM que voltou da suspensão com o relógio desatualizado produz exatamente os erros jwt not active e "nbf" claim timestamp check failed da saída abaixo.
Erro 6: aceitar o tipo errado de token
Um provedor OpenID Connect assina tokens de ID e tokens de acesso com a mesma chave, muitas vezes com o mesmo iss e às vezes com valores de aud que se sobrepõem. Uma API que verifica apenas assinatura, emissor e público-alvo pode aceitar um token de ID como token de acesso. A RFC 8725 §3.11 recomenda tipagem explícita, e a RFC 9068 a torna obrigatória para tokens de acesso JWT: o cabeçalho typ deve ser at+jwt (ou application/at+jwt), e os servidores de recursos devem rejeitar qualquer outro valor. Se seu provedor emite tokens da RFC 9068, verifique typ; se não emite, verifique uma claim que só os tokens de acesso carregam, como scope ou client_id.
Erro 7: chamar decode quando o certo era verify
Toda biblioteca tem uma função que analisa um token sem verificá-lo: jwt.decode() no jsonwebtoken, decodeJwt() no jose, jwt.decode(..., options={"verify_signature": False}) no PyJWT, ParseUnverified no golang-jwt. Elas existem para roteamento (ler iss para escolher um cliente JWKS) e para depuração. Na revisão de código, convém tratar qualquer uso delas no caminho de uma requisição como suspeito, e qualquer decisão baseada na saída delas, exceto escolher qual verificador chamar em seguida, como um bug.
Erro 8: buscar o JWKS a cada requisição, ou nunca
Buscar o conjunto de chaves a cada requisição adiciona uma ida e volta de rede a cada chamada e faz da disponibilidade do provedor de identidade a sua. Mantê-lo em cache para sempre significa que a primeira rotação de chaves quebra todas as requisições até uma reinicialização. O comportamento certo, que os clientes JWKS abaixo implementam, é um cache por tempo mais uma atualização quando um token indica um kid desconhecido, com um limite de taxa nessa atualização, para que uma enxurrada de tokens com valores de kid aleatórios não transforme sua API em um gerador de carga contra o provedor.
| Cliente | Tempo de vida do cache (padrão) | kid desconhecido | Tempo limite da busca |
|---|---|---|---|
jose createRemoteJWKSet | 10 minutos (cacheMaxAge) | nova busca, no máximo uma vez a cada 30 s (cooldownDuration) | 5 s |
| jwks-rsa 4.1.0 | 10 minutos por chave, 5 chaves | nova busca a cada chave não encontrada; rateLimit fica desligado, a menos que seja ativado (nesse caso, 10 por minuto) | 30 s |
PyJWT PyJWKClient | 300 s (lifespan) | nova busca, no máximo uma vez a cada 30 s (cooldown_duration) | 30 s |
keyfunc v3 NewDefault | atualização em segundo plano a cada hora | nova busca, no máximo uma vez a cada 5 minutos | 1 minuto |
Os valores são os padrões das versões listadas no início, lidos do código-fonte de cada uma. A diferença é mensurável: 20 tokens com valores de kid aleatórios causaram 20 buscas do conjunto de chaves com os padrões do jwks-rsa, 10 com rateLimit: true (os outros dez falharam rápido com JwksRateLimitError) e uma única busca com o jose. Crie o cliente uma vez por emissor e reutilize-o: um cliente JWKS construído dentro da função que trata a requisição começa com o cache vazio toda vez. A rotação em si, do lado do emissor, é tratada em rotação de chaves JWKS sem interrupção.
Um verificador que acerta, em quatro bibliotecas
Os quatro verificadores abaixo fazem a mesma coisa: só ES256, chaves vindas da URL do JWKS do emissor, iss e aud exatos, exp obrigatória, typ: at+jwt, 30 segundos de tolerância. Cada um foi executado com os mesmos oito tokens.
import { createRemoteJWKSet, jwtVerify } from "jose";
// Created once per issuer and reused: caches the key set, re-fetches it
// when a token names an unknown kid (at most once per cooldown).
const JWKS = createRemoteJWKSet(new URL(process.env.JWKS_URL), {
cacheMaxAge: 600_000, // re-fetch at least every 10 minutes
cooldownDuration: 30_000, // at most one kid-miss re-fetch per 30 s
});
export async function verifyAccessToken(token) {
const { payload } = await jwtVerify(token, JWKS, {
algorithms: ["ES256"], // fixed by the verifier, never taken from the token
issuer: "https://id.example.com",
audience: "https://api.example.com",
typ: "at+jwt", // RFC 9068 access token, not an ID token
requiredClaims: ["exp", "sub"], // exp is only checked when present
clockTolerance: 30, // seconds
});
return payload;
}
import jwt from "jsonwebtoken";
import jwksClient from "jwks-rsa";
const client = jwksClient({
jwksUri: process.env.JWKS_URL,
cache: true, // per-kid cache, 10 minutes by default
rateLimit: true, // off by default; caps JWKS fetches at 10 per minute
});
const getKey = (header, callback) =>
client.getSigningKey(header.kid, (err, key) => callback(err, key?.getPublicKey()));
export function verifyAccessToken(token) {
const options = {
algorithms: ["ES256"],
issuer: "https://id.example.com",
audience: "https://api.example.com",
clockTolerance: 30,
complete: true, // also return the header, to check typ
};
return new Promise((resolve, reject) =>
jwt.verify(token, getKey, options, (err, decoded) => {
if (err) return reject(err);
if (decoded.header.typ !== "at+jwt") return reject(new Error("unexpected typ"));
if (typeof decoded.payload.exp !== "number") return reject(new Error("exp is required"));
resolve(decoded.payload);
}),
);
}
import os
import jwt
# One client per issuer, reused across requests: caches the key set and
# re-fetches on an unknown kid (at most once per cooldown).
jwks = jwt.PyJWKClient(os.environ["JWKS_URL"], lifespan=600)
def verify_access_token(token: str) -> dict:
signing_key = jwks.get_signing_key_from_jwt(token)
decoded = jwt.decode_complete(
token,
signing_key,
algorithms=["ES256"], # required argument in PyJWT 2.x
issuer="https://id.example.com",
audience="https://api.example.com",
leeway=30,
options={"require": ["exp", "iss", "aud", "sub"]},
)
if decoded["header"].get("typ") != "at+jwt":
raise jwt.InvalidTokenError("unexpected typ")
return decoded["payload"]
package main
import (
"context"
"errors"
"time"
"github.com/MicahParks/keyfunc/v3"
"github.com/golang-jwt/jwt/v5"
)
type Verifier struct {
jwks keyfunc.Keyfunc
parser *jwt.Parser
}
func NewVerifier(ctx context.Context, jwksURL string) (*Verifier, error) {
// Background refresh every hour; an unknown kid triggers at most
// one extra fetch per 5 minutes (keyfunc defaults).
jwks, err := keyfunc.NewDefaultCtx(ctx, []string{jwksURL})
if err != nil {
return nil, err
}
parser := jwt.NewParser(
jwt.WithValidMethods([]string{"ES256"}),
jwt.WithIssuer("https://id.example.com"),
jwt.WithAudience("https://api.example.com"),
jwt.WithExpirationRequired(),
jwt.WithLeeway(30*time.Second),
)
return &Verifier{jwks: jwks, parser: parser}, nil
}
func (v *Verifier) VerifyAccessToken(raw string) (jwt.MapClaims, error) {
claims := jwt.MapClaims{}
token, err := v.parser.ParseWithClaims(raw, claims, v.jwks.Keyfunc)
if err != nil {
return nil, err
}
if token.Header["typ"] != "at+jwt" {
return nil, errors.New("unexpected typ")
}
return claims, nil
}
Os tokens de teste: um token válido e sete que quebram, cada um, exatamente uma regra. O JWKS foi servido via HTTP de dentro do contêiner, e cada biblioteca o buscou pelo próprio cliente JWKS.
--- jose
ok ACCEPTED sub=user-42
aud for another API ERR_JWT_CLAIM_VALIDATION_FAILED: unexpected "aud" claim value
no exp ERR_JWT_CLAIM_VALIDATION_FAILED: missing required "exp" claim
expired 1 h ago ERR_JWT_EXPIRED: "exp" claim timestamp check failed
nbf in 1 h ERR_JWT_CLAIM_VALIDATION_FAILED: "nbf" claim timestamp check failed
typ JWT (an ID token) ERR_JWT_CLAIM_VALIDATION_FAILED: unexpected "typ" JWT header value
unknown kid ERR_JWKS_NO_MATCHING_KEY: no applicable key found in the JSON Web Key Set
HS256 keyed with the public PEM ERR_JOSE_ALG_NOT_ALLOWED: "alg" (Algorithm) Header Parameter value not allowed
--- jsonwebtoken
ok ACCEPTED sub=user-42
aud for another API JsonWebTokenError: jwt audience invalid. expected: https://api.example.com
no exp Error: exp is required
expired 1 h ago TokenExpiredError: jwt expired
nbf in 1 h NotBeforeError: jwt not active
typ JWT (an ID token) Error: unexpected typ
unknown kid JsonWebTokenError: error in secret or public key callback: Unable to find a signing key that matches '2026-09-z'
HS256 keyed with the public PEM JsonWebTokenError: invalid algorithm
--- PyJWT
ok ACCEPTED sub=user-42
aud for another API InvalidAudienceError: Audience doesn't match
no exp MissingRequiredClaimError: Token is missing the "exp" claim
expired 1 h ago ExpiredSignatureError: Signature has expired
nbf in 1 h ImmatureSignatureError: The token is not yet valid (nbf)
typ JWT (an ID token) InvalidTokenError: unexpected typ
unknown kid PyJWKClientError: Unable to find a signing key that matches: "2026-09-z"
HS256 keyed with the public PEM InvalidAlgorithmError: The specified alg value is not allowed
--- golang-jwt v5 + keyfunc v3
ok ACCEPTED sub=user-42
aud for another API token has invalid claims: token has invalid audience
no exp token has invalid claims: token is missing required claim: exp claim is required
expired 1 h ago token has invalid claims: token is expired
nbf in 1 h token has invalid claims: token is not valid yet
typ JWT (an ID token) unexpected typ
unknown kid token is unverifiable: error while executing keyfunc: key not found "2026-09-z"
failed keyfunc: could not read JWK from storage
HS256 keyed with the public PEM token signature is invalid: signing method HS256 is invalid
A maioria das mensagens das bibliotecas mostradas acima tem sua própria página no catálogo de erros, com causas e correções, por exemplo unexpected "aud" claim value, jwt not active e no applicable key found in the JSON Web Key Set.
Lista de verificação
- Uma configuração de verificador por emissor, montada na inicialização: URL do JWKS, emissor, público-alvo, lista de algoritmos.
- Algoritmos: uma lista de permissões fixa, geralmente com um único valor. Nunca derivada do token, nunca contendo
none. - Chaves: apenas do JWKS ou do arquivo de chave configurado;
kidusado como chave de busca em um mapa;jku,x5uejwkembutida ignorados em tokens de acesso. - Claims:
expobrigatória;isseaudcomparados de forma exata;nbfrespeitada; tolerância de 30 a 60 segundos. - Tipo de token:
typverificado (at+jwtpara tokens de acesso da RFC 9068) ou outra claim que separe tokens de acesso de tokens de ID. - Cache do JWKS: um cliente, reutilizado; atualização por tempo; atualização por
kiddesconhecido com limite de taxa. - Nenhum
decodesem verificação no caminho de uma requisição. - Testes: um token válido e, para cada regra acima, um token que deve falhar. Os oito tokens deste artigo são um conjunto inicial.
Fontes
- RFC 7519: JSON Web Token, §4.1 (claims registradas), §7.2 (validação)
- RFC 7518: JSON Web Algorithms, §3.6 (
none) - RFC 8725: JSON Web Token Best Current Practices, §2.1, §3.1, §3.8 a §3.11
- draft-ietf-oauth-rfc8725bis, a atualização da RFC 8725
- RFC 9068: JWT Profile for OAuth 2.0 Access Tokens, §2.1 e §4
- Documentação do jose:
jwtVerify,createRemoteJWKSet - READMEs do jsonwebtoken e do jwks-rsa
- Documentação do PyJWT: uso,
PyJWKClient - golang-jwt/jwt v5 e keyfunc v3