Erros com JWT que viram vazamentos
Última atualização:
Um JSON Web Token é uma declaração assinada, não um envelope selado. São três segmentos codificados em Base64url unidos por pontos: um cabeçalho que nomeia o algoritmo de assinatura, um payload de claims sobre um usuário ou uma sessão, e uma assinatura sobre os dois primeiros. A assinatura prova que o token foi emitido por quem detém a chave e não foi alterado desde então. Não prova nada sobre segredo: qualquer pessoa com o token pode decodificar o payload e ler cada claim nele, e é por isso que decodificar um exige colar, não uma senha. Quando JWTs falham em produção, quase nunca é a criptografia que quebrou. O que quebrou foi uma verificação que alguém pulou: o algoritmo aceito por confiança a partir do próprio token, a expiração que ninguém validou, o segredo compartilhado que um humano conseguiria digitar de memória. Cole qualquer token no decodificador JWT e o cabeçalho e as claims ficam à vista. Este artigo percorre os erros de engenharia que transformam essas verificações puladas em vazamentos, e a regra defensiva que fecha cada um.
Assinado não é o mesmo que secreto
O payload é Base64url — uma codificação, da mesma família do Base64 comum —, não criptografia. Tudo nele é legível por qualquer um por quem o token passe: endereços de e-mail, funções, IDs de tenant, números internos de conta e, ocasionalmente, coisas muito piores, como flags de redefinição de senha ou fragmentos de dados pessoais que um desenvolvedor adicionou para depuração e nunca removeu. Os tokens também viajam mais longe do que os desenvolvedores imaginam: ficam no armazenamento do navegador, atravessam logs de proxy e de aplicação, são copiados para relatórios de falha e colados em tickets de bug e conversas de chat. A regra prática decorre diretamente: nunca coloque num token uma claim que você não se sentiria confortável vendo impressa num arquivo de log, porque cedo ou tarde ela aparecerá num.
O mal-entendido é compreensível: um token compacto parece texto cifrado, todo caracteres impronunciáveis e pontos. Mas a assinatura apenas torna o token evidente a adulterações: altere uma claim e a verificação falha. Confidencialidade é uma construção diferente da mesma família de padrões (tokens criptografados, JWE), e raramente é o que um token de acesso comum é. Projete o payload como texto público com uma assinatura sobre ele: apenas identificadores e fatos de autorização, dados sensíveis buscados no servidor por ID, e tempos de vida curtos para que o que vazar expire rápido.
Deixar o token escolher o próprio algoritmo
O cabeçalho declara um alg, e a falha mais antiga do livro de JWT é um verificador que acredita nele. Um dos valores declarados é none: um token sem assinatura, assinatura nenhuma. Uma biblioteca ou uma verificação artesanal que obedece ao algoritmo declarado pelo próprio token pode receber um token com a assinatura vazia e aceitá-lo como genuíno. O RFC de boas práticas é direto sobre isso: tokens sem assinatura só são aceitáveis onde o próprio canal já garante integridade, e um token bearer que chega pela internet pública não é esse caso. O mesmo defeito de confiar no cabeçalho produz a confusão de algoritmo. Um sistema que emite tokens RS256 guarda uma chave privada e publica a chave pública correspondente para verificação. Se o verificador puder ser persuadido a processar o token como HS256, o material de verificação passa a ser a chave pública — pública por design, conhecida por todos — usada como segredo simétrico, e assinaturas que qualquer um pode produzir começam a verificar. Em nenhuma das duas falhas uma chave é roubada; o verificador simplesmente seguiu instruções escritas pelo remetente.
O cabeçalho carrega um segundo risco, mais silencioso: ponteiros para chaves. Parâmetros como kid (um ID de chave), jku e x5u (URLs onde vive uma chave ou um certificado) dizem ao verificador onde encontrar a chave de verificação — e chegam dentro do token, influenciado pelo atacante. Se um valor de kid fluir sem controle para um caminho de arquivo, uma consulta ao banco de dados ou uma busca de chaves, a própria busca se torna a superfície de ataque. As regras defensivas têm todas a mesma forma: fixe o algoritmo esperado na configuração do servidor e rejeite todo o resto; resolva as chaves a partir do seu próprio registro, com IDs de chave conferidos contra uma lista de permissões; nunca busque material de chave de uma URL que o token forneça; e trate cada campo do cabeçalho como entrada não confiável, porque é.
A assinatura passou; as claims nunca foram verificadas
Verificar uma assinatura responde a uma única pergunta: este token foi emitido pelo detentor da chave e deixado inalterado? Não diz nada sobre se o token deve ser aceito aqui, agora. Esse é o trabalho das claims, e a validação de claims é onde os sistemas em produção pulam etapas em silêncio. Primeiro exp: um verificador que nunca confere a expiração transforma cada token numa credencial permanente, de modo que um token vazado num log há três anos ainda autentica hoje. Depois iss: aceite tokens apenas dos emissores que você configurou, não de qualquer um que compartilhe um algoritmo. E aud, a omissão mais cara: organizações costumam operar vários serviços contra uma única chave de assinatura, e o token de um serviço de baixo valor — uma API de nível gratuito, uma ferramenta vizinha ao staging — torna-se então um token corretamente assinado que um serviço de alto valor aceita, porque a assinatura é genuína e ninguém perguntou para qual audiência o token foi emitido.
Nada disso é exótico. É uma lista de verificação, e o modo de falha é que as bibliotecas verificam a assinatura com entusiasmo enquanto deixam as verificações de claims como opcionais, desativadas ou problema de quem chama. A postura defensiva é exigir exp, iss e aud em cada verificação, falhar fechado diante de qualquer ausência ou divergência, e tolerar desvios de relógio em minutos, não em horas: a tolerância existe para relógios imperfeitos e, esticada o bastante, revive em silêncio tokens expirados. Depois teste os casos negativos como testaria um formulário de login: um token expirado, um de audiência errada e um de um emissor desconhecido devem ser todos rejeitados, e essas rejeições merecem ser registradas como o sinal de segurança que são.
Segredos de que um humano se lembra são segredos que um atacante pode testar
HS256 é simétrico: um único segredo compartilhado assina e verifica. Em bases de código reais esse segredo é com demasiada frequência uma palavra ou frase curta — o nome da empresa, um padrão de framework copiado de um tutorial, algo que um desenvolvedor conseguiria digitar de memória. Eis o princípio que torna isso fatal, e é uma propriedade do design, não de qualquer ferramenta: um token capturado carrega tudo o que é preciso para testar um segredo adivinhado offline. A mensagem é conhecida, a assinatura é conhecida, logo os palpites podem ser conferidos localmente, a toda velocidade, sem que o servidor jamais veja uma tentativa ou qualquer limite de taxa se aplique. A segurança de todo o esquema reduz-se, portanto, à entropia dessa única cadeia — e cadeias escolhidas por humanos têm muito pouca.
As correções não têm glamour. Gere segredos de assinatura com uma máquina, com comprimento adequado — pelo menos 256 bits de entropia para HMAC — e guarde-os como as credenciais de produção que são. Torne-os únicos por ambiente, para que o segredo de uma configuração de staging vazada não compre nada em produção, e nunca deixe um padrão de desenvolvimento sobreviver a um deploy real. Onde muitos serviços verificam tokens, prefira algoritmos assimétricos como RS256 ou ES256: os verificadores ficam então apenas com chaves públicas, de modo que um verificador comprometido não vaza nada com que cunhar tokens, e a chave de assinatura permanece num só lugar. E tenha uma história de rotação antes de precisar dela, porque com um segredo simétrico nenhum alarme toca quando ele vaza: o primeiro sinal costuma ser um token que você não emitiu sendo aceito como seu.
Onde o navegador guarda o token decide quem pode lê-lo
No cliente, a escolha de armazenamento é uma decisão de segurança, alguém a tenha tomado deliberadamente ou não. Um token em localStorage é legível por cada script que roda na página: o seu próprio código, cada tag de terceiros e snippet de analytics que você admitiu — e qualquer carga de cross-site scripting que venha a executar, venha ela de qualquer campo de comentário sem escape em qualquer canto da aplicação. Um token bearer é uma credencial copiável: quem o lê pode apresentá-lo de qualquer lugar e ser aquele usuário até ele expirar. Tokens longevos em armazenamento legível por scripts valem, portanto, exatamente o que vale o seu pior bug de XSS, e a maioria das aplicações acaba tendo um.
A alternativa habitual, um cookie HttpOnly, remove por completo o acesso de scripts — uma carga de XSS já não consegue ler e exfiltrar o token —, mas o navegador anexa o cookie automaticamente, o que torna a falsificação de requisição entre sites a ameaça a combater, com atributos SameSite e tokens CSRF. Guardar tokens apenas em memória desvia dos dois problemas e perde a sessão a cada atualização. Não existe uma resposta universalmente correta, e fingir o contrário é como as equipes acabam com o pior de cada opção. O que transforma armazenamento em vazamento é a combinação que ninguém escolheu de propósito: armazenamento legível por scripts, tempos de vida longos, escopos amplos e nenhuma rotação de tokens de atualização para encolher o valor de uma cópia. Escolha o compromisso conscientemente, mantenha curtos os tempos de vida dos tokens de acesso em qualquer caso, e escreva o raciocínio onde o próximo desenvolvedor o encontre.