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:
- O servidor espera
RS256e tem só a chave pública para verificar. - O atacante forja um token com
alg: HS256. - Ele assina esse token com HMAC usando a chave pública (o PEM em texto) como segredo.
- 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
kidpara 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
kidalimenta 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:
expignorado: 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 outrosub/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 vetorAV: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/jkualterados, 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 noalgdo token. - Rejeitar explicitamente
alg: nonee suas variações de capitalização. - Garantir que a assinatura é realmente verificada (jamais
verify_signature: Falseem produção). - Segredo HMAC aleatório de ≥256 bits; chaves assimétricas em KMS/HSM com rotação.
- Validar
exp,nbf,audeissem toda verificação (requiredas claims essenciais). -
kidresolvido contra um conjunto fechado de chaves — sem path traversal, sem SQL. - Ignorar
jku/x5udo token; JWKS configurado server-side ou allowlist estrita de hosts. - TTL curto no access token + refresh token revogável + denylist por
jtino 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/hashcatno 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.