Você abre o README do PyJWT, copia o exemplo de dez linhas, gera um token, valida do outro lado — e funciona. O token tem o user_id, expira em uma hora, a assinatura bate. O que pode estar errado?
Bastante coisa. JWT é um dos padrões mais mal implementados em aplicações web, não porque seja complicado, mas porque os exemplos básicos funcionam mesmo com configurações que criam vulnerabilidades sérias. O código roda, os testes passam, e os problemas aparecem meses depois — ou não aparecem, porque ninguém tentou explorar.
Este artigo começa com uma implementação que parece correta e mostra os três erros mais comuns: o algoritmo none, tokens sem revogação, e segredos fracos. Para cada um: o que está errado, como explorar, e como corrigir.
O ponto de partida
| |
Funciona. Mas tem três problemas que vamos desmontar um a um.
Erro 1: aceitar o algoritmo que o token declara
O JWT tem três partes separadas por ponto: header, payload e assinatura, todas em Base64URL. O header declara qual algoritmo foi usado para assinar. Numa implementação ingênua, o servidor lê esse header e usa o algoritmo que o token informa para validar — não o algoritmo que o servidor espera.
O algoritmo none é válido pela especificação original do JWT. Ele significa “sem assinatura”. Um token com "alg": "none" no header e assinatura vazia é tecnicamente bem-formado, e versões antigas de bibliotecas (incluindo PyJWT < 2.0) aceitavam isso por padrão.
O ataque é direto: pegar um token válido, decodificar o Base64URL do header e do payload, modificar o payload (por exemplo, trocar "sub": "42" por "sub": "1" para virar admin), remontar com "alg": "none" e assinatura vazia.
| |
Na versão atual do PyJWT, isso já não funciona da forma mais óbvia — a biblioteca exige que você passe algorithms explicitamente no decode, e ela rejeita none por padrão se você passar qualquer algoritmo real. Mas o problema continua latente em duas situações.
A primeira é quando o código usa options={"verify_signature": False} para “testar” e esse flag escapa para produção. A segunda é a variante RS256 → HS256: quando o servidor usa RS256 (chave pública/privada), um atacante pode tentar recodificar o token com HS256 usando a chave pública como segredo, se o servidor aceitar ambos os algoritmos.
O fix é simples e deve ser seguido à risca: nunca use algorithms=["HS256", "RS256"] junto. Defina um algoritmo único e seja explícito.
| |
Se a aplicação precisa suportar múltiplos algoritmos por razões de migração, use lógica explícita: inspecione o header fora do decode, valide que o algoritmo está na lista de permitidos, e só então decodifique.
Erro 2: não ter mecanismo de revogação
JWT é stateless por design. O servidor não guarda nada — a validade está dentro do token. Isso é a vantagem principal e o problema principal ao mesmo tempo.
Considere o cenário mais comum: um usuário reporta que o celular foi roubado. Você quer invalidar a sessão dele imediatamente. Com JWT puro, não tem como. O token continua válido até expirar, e se o exp for de 24 horas ou 7 dias (o que é comum em apps mobile), o atacante tem uma janela enorme.
O mesmo problema aparece em logout. A maioria das implementações de “logout com JWT” simplesmente apaga o token do lado do cliente. O token em si continua válido. Quem capturou via XSS ou interceptação ainda consegue usar.
Há duas abordagens para revogação. A primeira é manter uma blocklist de tokens revogados. O servidor guarda os jti (JWT ID) dos tokens invalidados, e verifica na blocklist a cada request. O jti é uma claim padrão do JWT, um UUID único por token.
| |
A segunda abordagem é usar tokens de vida curta com refresh tokens. O access token expira em 15 minutos. O refresh token (opaco, armazenado no servidor) dura 30 dias e é o único que precisa de revogação. Na maioria dos fluxos de logout e sessão comprometida, só o refresh token precisa ser invalidado.
| |
As duas abordagens introduzem estado no servidor, o que tecnicamente compromete o “stateless puro” do JWT. Isso é um trade-off real. A escolha depende do contexto: para APIs públicas com tokens de vida muito curta, a blocklist pode ser dispensada. Para qualquer coisa com usuários reais e sessões que importam, alguma forma de revogação é necessária.
Erro 3: segredo fraco e sem rotação
O código do início usa SECRET = "secret". Isso é óbvio demais para passar em code review, mas variações do problema são comuns: segredos de 16 caracteres, segredos hardcoded no código (commitados no git), segredos lidos de variáveis de ambiente sem validação de tamanho.
HS256 é um HMAC com SHA-256. A força da assinatura depende inteiramente do comprimento e da entropia do segredo. Um segredo curto é vulnerável a ataques de força bruta offline — o atacante captura um token válido e testa segredos até a assinatura bater. Ferramentas como hashcat têm suporte nativo a JWT HS256.
O mínimo razoável para HS256 é um segredo de 256 bits (32 bytes) gerado com um CSPRNG. Não uma string legível por humanos — bytes aleatórios.
| |
Para produção, o segredo não deve estar no código nem no .env commitado. O artigo sobre variáveis de ambiente e secrets vai cobrir isso em detalhe, mas o fluxo básico é: segredo gerado uma vez, armazenado em cofre (Vault, AWS Secrets Manager, GCP Secret Manager), injetado como variável de ambiente na inicialização do serviço.
Se a aplicação já usa RS256 ou ES256, o problema do segredo fraco não existe da mesma forma — mas a gestão da chave privada vira o problema equivalente. Chave privada no repositório é o mesmo erro com outro nome.
A rotação de segredos é o passo que quase ninguém implementa. O plano mínimo: suporte a múltiplas versões de chave no verify (aceitar tokens assinados com a chave anterior durante uma janela de transição), troque a chave periodicamente, invalide tokens antigos após a janela.
| |
Juntando tudo
Uma implementação que cobre os três problemas fica assim:
| |
Algoritmo fixo no verify, jti em todo token, revogação via Redis com TTL automático, segredo com entropia adequada. Não é a implementação mais sofisticada possível — mas cobre o que a maioria das implementações “que funcionam” não cobre.
JWT é um daqueles padrões que a especificação deixou flexível demais para ser seguro por padrão. Defaults ruins, algoritmos opcionais problemáticos, ausência de revogação no design original. Conhecendo as armadilhas, dá para usar bem. Sem conhecê-las, é fácil ter uma implementação que passa em todos os testes e falha quando importa.
Se você usa JWT de forma diferente do que está aqui — ou se encontrou outros erros comuns que ficaram de fora — a conversa continua no Fediverse em @riverfount@bolha.us.
