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

Erros na verificação de JWT que aceitam tokens inválidos

Uma biblioteca de JWT verifica exatamente o que você pede, e nada além disso. Veja o que quatro bibliotecas populares aceitam quando você omite as opções, os erros que vêm daí e um verificador que rejeita os tokens certos, executado com tokens de teste reais.

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"]):

Tokenjose jwtVerifyjsonwebtoken verifyPyJWT decodegolang-jwt Parse
Assinado pela chave certa, mas com iss e aud de outro serviçoaceitoaceitorejeitado: Invalid audienceaceito
Assinado pela chave certa, sem a claim expaceitoaceitoaceitoaceito
alg: "none", assinatura vaziarejeitadorejeitadorejeitadorejeitado

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:

naive.mjs: o antipadrão, não leve para produção
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));
Saída (node:24.22.0-bookworm-slim)
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 apenas ES256, ES384 e ES512.
  • 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.PublicKey falha 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 com KeyObject 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 kid em um mapa, não em uma consulta ou em um caminho. Um kid que 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 de kid das chaves que buscaram na URL que você configurou, e esse é o formato certo.
  • Ignore jku e x5u. 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 jwk ou um x5c embutido 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 claim cnf do token de acesso). Veja DPoP explicado.
  • Trate a ausência de kid de 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. aud e iss são strings que diferenciam maiúsculas de minúsculas, e https://id.example.com não é https://id.example.com/. Copie o emissor do documento de descoberta do provedor, não de um token que você tenha à mão.
  • aud pode 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 tem options={"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 pelo iss nã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 de iss é 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.

ClienteTempo de vida do cache (padrão)kid desconhecidoTempo limite da busca
jose createRemoteJWKSet10 minutos (cacheMaxAge)nova busca, no máximo uma vez a cada 30 s (cooldownDuration)5 s
jwks-rsa 4.1.010 minutos por chave, 5 chavesnova busca a cada chave não encontrada; rateLimit fica desligado, a menos que seja ativado (nesse caso, 10 por minuto)30 s
PyJWT PyJWKClient300 s (lifespan)nova busca, no máximo uma vez a cada 30 s (cooldown_duration)30 s
keyfunc v3 NewDefaultatualização em segundo plano a cada horanova busca, no máximo uma vez a cada 5 minutos1 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.

verify-jose.mjs (jose 6.2.13)
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;
}
verify-jsonwebtoken.mjs (jsonwebtoken 9.0.3, jwks-rsa 4.1.0)
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);
    }),
  );
}
verify_pyjwt.py (PyJWT 2.15.1, cryptography 50.0.2)
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"]
verify.go (golang-jwt v5.3.1, keyfunc v3.8.2)
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.

Saída: jose e jsonwebtoken (node:24.22.0-bookworm-slim)
--- 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
Saída: PyJWT (python:3.14.8-slim-trixie) e golang-jwt (golang:1.27.3-trixie)
--- 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

  1. Uma configuração de verificador por emissor, montada na inicialização: URL do JWKS, emissor, público-alvo, lista de algoritmos.
  2. Algoritmos: uma lista de permissões fixa, geralmente com um único valor. Nunca derivada do token, nunca contendo none.
  3. Chaves: apenas do JWKS ou do arquivo de chave configurado; kid usado como chave de busca em um mapa; jku, x5u e jwk embutida ignorados em tokens de acesso.
  4. Claims: exp obrigatória; iss e aud comparados de forma exata; nbf respeitada; tolerância de 30 a 60 segundos.
  5. Tipo de token: typ verificado (at+jwt para tokens de acesso da RFC 9068) ou outra claim que separe tokens de acesso de tokens de ID.
  6. Cache do JWKS: um cliente, reutilizado; atualização por tempo; atualização por kid desconhecido com limite de taxa.
  7. Nenhum decode sem verificação no caminho de uma requisição.
  8. 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