JSON Web Tokens (JWT) viraram o padrão de fato para autenticação stateless em APIs: o servidor emite um token assinado, o cliente o reapresenta a cada requisição e o servidor confia naquilo desde que a assinatura bata. O problema é que toda essa confiança mora em um detalhe frágil — a verificação da assinatura. Quando ela é mal implementada, o token deixa de ser uma credencial e vira um formulário editável: o atacante reescreve quem ele é, qual papel tem e por quanto tempo, e o backend acredita.

JWT é um daqueles temas em que pentester e desenvolvedor precisam falar a mesma língua. Para quem ataca, é uma das categorias com maior retorno: poucos cliques no Burp separam um usuário comum de uma sessão de administrador. Para quem desenvolve, a maioria das falhas vem de confiar em campos que o próprio atacante controla — a começar pelo header alg. Este artigo cobre as armadilhas clássicas (alg: none, confusão de algoritmo, segredos fracos) e as menos óbvias (kid, jku, claims não validadas, ausência de revogação), com código reproduzível dos dois lados.

Antes de atacar, entenda a estrutura. Se as siglas JWT, JWS, JWE, JWA e JWK ainda se confundem, comece por JWT, JWS, JWE, JWA e JWK: a diferença entre as siglas do JOSE — ele dá o mapa mental que torna estes ataques óbvios.

Como funciona

Um JWT é composto por três partes separadas por ponto: header.payload.signature. As duas primeiras são apenas base64url de um JSON — não há criptografia, só codificação. Qualquer um decodifica e lê o conteúdo. A terceira parte é a assinatura, que prova que o token foi emitido por quem detém a chave.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJyb2xlIjoidXNlciJ9.K8w3Q...
└────────── header ──────────┘ └───────────── payload ─────────────┘ └─ assinatura ┘

Decodificando as duas primeiras partes:

// header
{ "alg": "HS256", "typ": "JWT" }
// payload
{ "sub": "123", "role": "user", "exp": 1769900000 }

Repare no ponto mais importante para um pentester: o payload não é secreto. Ele está em base64url, legível por qualquer pessoa que intercepte o token. Isso é uma propriedade do JWS (JSON Web Signature) — o formato assinado, que é o que a esmagadora maioria das aplicações usa. Existe também o JWE (JSON Web Encryption), de fato criptografado, mas é raro na prática. Como diferenciar: um JWS tem três partes (header.payload.signature); um JWE tem cinco partes separadas por ponto. Se você está olhando um eyJ... com três segmentos, é JWS e o conteúdo está exposto.

A assinatura HS256 é um HMAC-SHA256 calculado assim:

import hmac, hashlib, base64

def b64url(b: bytes) -> str:
    return base64.urlsafe_b64encode(b).rstrip(b"=").decode()

header  = b64url(b'{"alg":"HS256","typ":"JWT"}')
payload = b64url(b'{"sub":"123","role":"user"}')
signing_input = f"{header}.{payload}".encode()

# O segredo é compartilhado: o MESMO valor assina e verifica
secret = b"super-segredo-do-servidor"
sig = hmac.new(secret, signing_input, hashlib.sha256).digest()
token = f"{header}.{payload}.{b64url(sig)}"

O servidor verifica recalculando o HMAC sobre header.payload com seu segredo e comparando com a assinatura recebida (idealmente em tempo constante, com hmac.compare_digest). Se baterem, ele confia em tudo que está no payload. É exatamente aí que mora a vulnerabilidade: se a verificação puder ser enganada, o atacante controla as claims.

Variações e bypasses

Cada falha abaixo tem uma raiz comum — o backend confia em um dado que está sob controle do atacante (o header) ou usa um segredo fraco demais.

1. alg: none aceito

A especificação (RFC 7519/7518) prevê um algoritmo none, pensado para tokens “não protegidos” (Unsecured JWS). Se a biblioteca o aceita na verificação, o atacante simplesmente remove a assinatura e troca o algoritmo:

{ "alg": "none", "typ": "JWT" }
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjMiLCJyb2xlIjoiYWRtaW4ifQ.

O token termina com um ponto e nada depois (a assinatura vazia). Variantes de bypass incluem None, NONE, nOnE — algumas bibliotecas faziam comparação case-sensitive e bloqueavam só none minúsculo. Em bibliotecas modernas que exigem uma allowlist de algoritmos na verificação, none só passa se for explicitamente incluído nessa lista; é por isso que a allowlist (ver mitigação) mata essa classe inteira.

2. Confusão de algoritmo (RS256 → HS256)

Esta é a mais elegante. Em RS256, a chave privada assina e a pública verifica — e a pública é, por definição, distribuível (muitas vezes exposta em /.well-known/jwks.json). O ataque explora bibliotecas que escolhem o verificador a partir do campo alg do token:

  1. O servidor espera RS256 e tem só a chave pública para verificar.
  2. O atacante forja um token com alg: HS256.
  3. Ele assina esse token com HMAC usando a chave pública (o PEM em texto) como segredo.
  4. A biblioteca lê alg: HS256, pega “a chave do servidor” (a pública), e a usa como segredo HMAC — que é exatamente o que o atacante usou. A assinatura bate.

O atacante não precisa da chave privada: ele transforma um dado público em segredo simétrico. Detalhe crítico de reprodução: o segredo HMAC tem que ser exatamente os mesmos bytes que o servidor passa ao verificador (geralmente o PEM completo, com cabeçalhos -----BEGIN PUBLIC KEY----- e quebras de linha). Um byte de diferença e a assinatura não confere.

# Forja de confusão de algoritmo — demonstração defensiva.
# AVISO: PyJWT >= 1.5 BLOQUEIA isto, levantando InvalidKeyError
# ("asymmetric key ... should not be used as an HMAC secret").
# O ataque só funciona contra bibliotecas que NÃO têm essa proteção
# (versões antigas de várias libs, ou implementações próprias).
# Para reproduzir contra um alvo vulnerável use jwt_tool (mais abaixo).
import jwt  # PyJWT

with open("public.pem") as f:
    public_key = f.read()  # chave PÚBLICA, obtida do JWKS

forged = jwt.encode(
    {"sub": "123", "role": "admin"},
    key=public_key,      # a chave pública vira o "segredo"
    algorithm="HS256",   # downgrade de RS256 para HS256
)

3. Segredo HMAC fraco / curto

Se a aplicação usa HS256 com um segredo curto, de dicionário ou um valor de exemplo copiado de um tutorial (secret, your-256-bit-secret, changeme), ele é quebrável offline. O atacante captura um único token válido e roda força bruta local até achar o segredo que reproduz a assinatura. A partir daí, ele assina o que quiser. Note que isso só vale para HMAC (HS*): um RS256/ES256 não é “crackeável” por wordlist, porque a verificação usa a chave pública e a forja exigiria a chave privada.

4. Assinatura simplesmente não verificada

Mais comum do que parece: o backend decodifica o token (lê as claims) mas nunca chama a função que verifica a assinatura. Em PyJWT, é a diferença entre jwt.decode(token, options={"verify_signature": False}) e a verificação real com chave e algorithms. Nesse cenário não importa o algoritmo — qualquer payload editado é aceito.

5. Injeção via header kid

O kid (key ID) indica qual chave usar. Se o backend usa esse valor para buscar a chave sem sanitização, ele vira vetor de injeção:

  • Path traversal: apontar kid para um arquivo de conteúdo conhecido e previsível e usá-lo como segredo HMAC.
{ "alg": "HS256", "kid": "../../../../dev/null" }

Se o servidor lê o arquivo apontado por kid e usa o conteúdo como chave, /dev/null devolve conteúdo vazio — o atacante assina com segredo vazio ("") e a verificação passa. O mesmo vale para qualquer arquivo de conteúdo estável e conhecido pelo atacante (a “chave” passa a ser o conteúdo desse arquivo).

  • SQL Injection: se o kid alimenta uma query (SELECT key FROM keys WHERE id = '<kid>'), o atacante injeta SQL para fazer o banco retornar um valor que ele controla, usado depois como chave.
{ "alg": "HS256", "kid": "x' UNION SELECT 'chave-controlada-pelo-atacante' -- " }

6. jku / x5u apontando para o atacante

Os headers jku (URL de um JWKS) e x5u (URL de um certificado X.509) dizem ao servidor onde buscar a chave de verificação. Se o backend confia cegamente nessa URL, o atacante hospeda seu próprio JWKS, aponta jku para lá e o servidor baixa a chave pública do atacante para verificar — token forjado validado. Além da forja direta, isso vira SSRF: a URL pode apontar para serviços internos (metadados de nuvem, painéis administrativos, etc.).

{ "alg": "RS256", "jku": "https://atacante.example/jwks.json", "kid": "1" }

7. exp / nbf / aud / iss não validados

Mesmo com assinatura correta, faltam validações semânticas:

  • exp ignorado: tokens nunca expiram; um token vazado vale para sempre.
  • aud (audience) ignorado: um token emitido para o serviço A é aceito pelo serviço B (cross-service token reuse).
  • iss (issuer) ignorado: o backend aceita tokens de qualquer emissor, inclusive de um tenant de outro cliente no mesmo provedor de identidade.
  • nbf (not before) ignorado: tokens “do futuro” são aceitos cedo demais.

8. Ausência de revogação no logout

JWT é stateless por design — o servidor não guarda sessão. A consequência é que logout não invalida o token: ele continua válido até o exp. Se um token vaza, não há botão de pânico, a menos que exista uma lista de revogação ou rotação de chave.

9. Dado sensível no payload

Como o payload é só base64url, colocar CPF, e-mail, número de cartão ou flags internas ali equivale a publicá-los. Qualquer um que veja o token (logs, histórico de proxy, localStorage) lê tudo.

Como exploramos no pentest

O fluxo no engajamento é metódico e quase sempre rápido.

Passo 1 — Capturar e decodificar. Pegue um token real do tráfego no Burp Suite. A extensão JWT Editor (ou JOSEPH) decodifica header e payload na própria interface. Anote alg, kid, e claims interessantes (role, is_admin, tenant, exp, aud, iss).

Passo 2 — Testar alg: none. Edite o header para {"alg":"none"}, mude role para admin, remova a assinatura (deixe o ponto final) e reenvie. Teste também None e NONE. Se a resposta autenticar, a falha está provada.

Passo 3 — Verificar se a assinatura é checada. Mantenha header e payload, mas altere um byte da assinatura. Se o servidor continua aceitando, ele não verifica nada — edite as claims à vontade.

Passo 4 — Tentar confusão de algoritmo. Se o token é RS256, obtenha a chave pública (de /.well-known/jwks.json, de um certificado TLS, ou derivando de dois tokens com a ferramenta jwt_tool). O jwt_tool automatiza o ataque (modo de exploração “key confusion”, -X k):

# Ataque de confusão de algoritmo (RS256 -> HS256) com a chave pública
jwt_tool TOKEN -X k -pk public.pem

No JWT Editor do Burp, o fluxo equivalente é importar a chave pública (o PEM exato) como chave simétrica e reassinar o token como HS256. Lembre-se: o que precisa bater são os bytes exatos do PEM que o servidor usa para verificar.

Passo 5 — Crackear segredo HMAC fraco. Só para tokens HS256/HS*, jogue o token em um cracker. Com hashcat (modo 16500, “JWT (JSON Web Token)”):

# Quebra offline de segredo HMAC de um JWT HS256
hashcat -a 0 -m 16500 token.jwt /usr/share/wordlists/rockyou.txt

Ou com jwt_tool em modo de crack por wordlist (-C -d <dicionário>):

jwt_tool TOKEN -C -d rockyou.txt

Achou o segredo? Reassine qualquer payload — inclusive {"role":"admin"} — com ele.

Passo 6 — Manipular claims e abusar de kid/jku. Com qualquer um dos vetores acima funcionando, escale: troque sub para o ID de outra conta (IDOR via token), eleve role, troque tenant. Teste kid com path traversal (../../../../dev/null para segredo vazio) e SQLi. Aponte jku para um Burp Collaborator — se o servidor faz a requisição, você confirmou SSRF e provavelmente forja via JWKS próprio.

Dica de relatório: sempre anexe o par de tokens (o original e o forjado) e a response autenticada com o privilégio elevado. “Consegui virar admin” sem o request/response não é evidência.

Resumo para o relatório

  • Impacto: falsificação de identidade e escalada de privilégio — o atacante forja um token válido com role: admin (ou outro sub/tenant), assumindo qualquer conta e contornando totalmente a autenticação e a autorização da aplicação.
  • Severidade: Crítica para alg:none/confusão de algoritmo — CVSS 3.1 = 9.8 com o vetor AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H. Segredo fraco crackeável tende a Alta/Crítica; claims não validadas, Média/Alta conforme o impacto (ajuste o vetor caso haja pré-condições como interação ou privilégio prévio).
  • Pré-condições: capacidade de interceptar/obter um token válido e reenviar requisições; para confusão de algoritmo, acesso à chave pública (frequentemente em /.well-known/jwks.json) e uma biblioteca/implementação sem proteção contra uso de chave assimétrica como segredo HMAC; para segredo fraco, apenas um token capturado.
  • Evidência sugerida: token original e token forjado (decodificados), o request com o token manipulado, a response autenticada exibindo o privilégio elevado, o alg/kid/jku alterados, e — no caso de segredo fraco — o segredo recuperado e a wordlist/tempo de quebra.

Como mitigar

A regra de ouro: nunca deixe o token escolher como será verificado. O servidor decide o algoritmo e a chave; o campo alg do token é entrada não confiável.

Fixe uma allowlist de algoritmo

O padrão perigoso, em qualquer linguagem, é deixar a verificação derivar o algoritmo do header do token (sem allowlist) — é isso que abre as portas para alg:none e para a confusão de algoritmo. Bibliotecas variam em como tratam isso:

# PERIGOSO (pseudocódigo / libs sem allowlist obrigatória):
# verificar o token usando o "alg" que veio no próprio header.
# Em PyJWT 2.x isto NÃO é possível: jwt.decode() EXIGE o argumento
# algorithms e levanta DecodeError sem ele — uma boa proteção padrão.
# Mas implementações próprias e libs antigas frequentemente confiam no
# header. NUNCA escreva código que escolha o verificador a partir do alg.

# CERTO — o servidor impõe o algoritmo; tudo fora da lista é rejeitado
payload = jwt.decode(
    token,
    key=PUBLIC_KEY,                 # chave pública RS256
    algorithms=["RS256"],           # ALLOWLIST explícita e única
    audience="api.intruderlabs",    # valida aud
    issuer="https://auth.intruderlabs",  # valida iss
    options={
        "require": ["exp", "iat", "aud", "iss"],
        "verify_signature": True,
        "verify_exp": True,
        "verify_aud": True,
        "verify_iss": True,
    },
)

Com algorithms=["RS256"], um token forjado com alg: HS256 é rejeitado antes de qualquer tentativa de verificação cruzada — a confusão de algoritmo morre aqui, assim como alg: none. Em outras stacks, o equivalente é: golang-jwt com jwt.WithValidMethods([...]) (e checando o tipo de método na Keyfunc); jose/jsonwebtoken (Node) passando { algorithms: ['RS256'] }; jjwt (Java) fixando o algoritmo esperado.

Segredo forte ou gestão de chaves adequada

Para HMAC, use um segredo aleatório de no mínimo 256 bits (32 bytes), nunca um valor de tutorial:

import secrets
SECRET = secrets.token_bytes(32)   # 256 bits de aleatoriedade real

Para assimétrico (RS256/ES256), guarde a chave privada em um cofre (KMS/HSM/secret manager), publique só a pública via JWKS, e rotacione periodicamente.

Trate kid, jku e x5u como entrada hostil

Nunca use kid para abrir arquivos nem para montar query. Mapeie-o contra um conjunto fixo de chaves conhecidas:

KEYS = {"2024-key": PUBLIC_KEY_2024, "2025-key": PUBLIC_KEY_2025}

def resolver_chave(kid: str):
    chave = KEYS.get(kid)          # lookup em dicionário fechado
    if chave is None:              # kid desconhecido = rejeita
        raise InvalidKeyError("kid não reconhecido")
    return chave

Para jku/x5u, não busque URLs do token. Configure o endpoint de JWKS no servidor (ou use uma allowlist estrita de hosts) e ignore o que vier no header.

TTL curto, refresh token e revogação

import time, secrets
# Access token de vida curta + jti para permitir revogação
now = int(time.time())
claims = {
    "sub": user_id, "role": role,
    "iat": now, "exp": now + 900,      # 15 min
    "aud": "api.intruderlabs", "iss": "https://auth.intruderlabs",
    "jti": secrets.token_hex(16),       # ID único do token
}

No logout (ou comprometimento), adicione o jti a uma denylist de curta duração (em Redis, com TTL = tempo restante do token). O access token de 15 minutos limita a janela; o refresh token, de vida longa e armazenado server-side, pode ser revogado de imediato.

Não coloque segredos no payload

Trate o payload como público. Guarde só identificadores (sub, role) e busque dados sensíveis no backend a partir deles. Se precisar de confidencialidade no próprio token, use JWE, não JWS.

Prefira bibliotecas que validam por padrão

Use bibliotecas mantidas que exigem algorithms e rejeitam none por padrão (PyJWT recente — que ainda bloqueia chave assimétrica como segredo HMAC —, jose, jjwt, golang-jwt com WithValidMethods). Defesa em profundidade: a allowlist de algoritmo, a validação de claims e a revogação são camadas independentes — nenhuma sozinha cobre tudo.

Checklist de mitigação

  • Allowlist de algoritmo fixa no servidor (algorithms=[...]); nunca confiar no alg do token.
  • Rejeitar explicitamente alg: none e suas variações de capitalização.
  • Garantir que a assinatura é realmente verificada (jamais verify_signature: False em produção).
  • Segredo HMAC aleatório de ≥256 bits; chaves assimétricas em KMS/HSM com rotação.
  • Validar exp, nbf, aud e iss em toda verificação (require das claims essenciais).
  • kid resolvido contra um conjunto fechado de chaves — sem path traversal, sem SQL.
  • Ignorar jku/x5u do token; JWKS configurado server-side ou allowlist estrita de hosts.
  • TTL curto no access token + refresh token revogável + denylist por jti no logout.
  • Nenhum dado sensível no payload; usar JWE se precisar de confidencialidade.
  • Biblioteca mantida que valida por padrão; testar regressões com jwt_tool/hashcat no pipeline.

JWT não é inseguro — é literal. Ele faz exatamente o que o código manda, inclusive confiar num token que diz “não me verifique”. A diferença entre uma credencial robusta e um formulário editável é uma única decisão: quem define o algoritmo e a chave, o servidor ou o atacante. Fixe essa decisão no servidor, e o resto do token vira apenas um dado que você já sabia conferir.