Skip to content
Voltar ao blog
Segurança

JWT invalid signature: todas as causas e como corrigir cada uma

Assinatura JWT inválida (invalid signature): bytes da chave conforme a linguagem, quebra de linha no .env, algoritmo incompatível. Decodificador grátis.

14 min de leitura

JWT invalid signature: todas as causas e como corrigir cada uma

Um erro de JWT invalid signature significa exatamente uma coisa: a assinatura que o seu verificador calculou não é igual à assinatura que veio dentro do token. É essa a mensagem inteira. Não quer dizer que o token expirou nem que o usuário perdeu permissão. Alguma coisa nos bytes que entram no HMAC, ou na chave pública que entra na chamada de verificação, é diferente entre o lado que assinou e o lado que confere.

Na maioria dos casos o culpado é o material da chave, não o token. Use este roteiro para escolher por onde começar:

Qual é o algoritmo no cabeçalho?
├─ HS256 / HS384 / HS512  → quase sempre um problema de segredo
│    ├─ assinante e verificador em linguagens diferentes? → Seção 3
│    └─ mesma linguagem, funciona local, falha em produção? → Seção 4
└─ RS256 / ES256 / PS256  → quase sempre formato de chave ou chave errada
     └─ → Seção 7

O token passou por gateway, proxy ou copiar-e-colar?      → Seção 6
O erro só aparece depois de algumas horas ou em um host?  → Seção 8

Cada seção abaixo termina com algo que você pode executar. Para o primeiro movimento mais rápido, cole o token no decodificador JWT e leia o campo alg: metade dos ramos acima desaparece assim que você souber esse valor.

1. O que “invalid signature” significa

Bibliotecas diferentes imprimem mensagens diferentes para a mesma falha. Localize a sua nesta lista para ter certeza de que está no guia certo:

  • Node jsonwebtoken: JsonWebTokenError: invalid signature
  • Python PyJWT: InvalidSignatureError: Signature verification failed
  • Java jjwt: SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.

As três disparam no mesmo momento, no mesmo caminho de código. A biblioteca pega os dois primeiros segmentos do seu token, recalcula a assinatura com a chave que você entregou e compara o resultado byte a byte com o terceiro segmento. Não deu igual, lança a exceção.

A comparação é exata e não carrega nenhuma informação sobre o quanto os dois valores diferem. Um segredo com um byte de diferença e uma chave completamente errada produzem mensagens de erro idênticas. É por isso que o resto deste guia trata de estreitar o espaço de entradas, e não de ler o erro com mais atenção.

Repare no que ainda não aconteceu quando esse erro dispara. A validação de claims roda depois da verificação da assinatura, então exp, nbf, aud e iss sequer foram olhados. Se a verificação de assinatura do seu JWT falhou, o conteúdo do token é irrelevante para o diagnóstico (embora continue legível, já que um JWT é codificado, não criptografado). Decodificar o cabeçalho e o payload não exige chave nenhuma; veja como decodificar um token JWT se quiser o passo a passo segmento por segmento.

Dois campos do cabeçalho decidem para onde você vai em seguida: alg diz se você está atrás de um segredo compartilhado ou de um par de chaves, e kid diz qual chave o assinante acreditava estar usando.

2. A assinatura cobre a string codificada, não o seu objeto

A maioria dos desenvolvedores entende esta parte ao contrário, e todo o resto depende dela.

A RFC 7515, especificação do JSON Web Signature, define o JWS Signing Input como a string ASCII:

BASE64URL(UTF8(JWS Protected Header)) || '.' || BASE64URL(JWS Payload)

O HMAC é calculado sobre essa string. Não sobre o seu mapa de claims, nem sobre nada que a sua linguagem considere dado estruturado. Este é o signing input usado ao longo de todo o artigo, extraído do payload de exemplo padrão:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ

A consequência é grave e pega equipes o tempo todo: qualquer camada que decodifique o payload e o codifique de novo destrói a assinatura. A serialização JSON não é canônica. A ordem das chaves muda quando um mapa faz ida e volta na maioria das linguagens. Espaços em branco aparecem ou somem. Caracteres não ASCII são escapados como \uXXXX por um serializador e emitidos literalmente por outro. Números são reformatados, então 1516239022 pode voltar como 1516239022.0. Cada um desses casos gera uma string base64url diferente, portanto um signing input diferente, portanto uma assinatura diferente.

Gatilhos reais que já vimos:

  • Um API gateway que faz o parse do JWT para enriquecê-lo com um ID de tenant e reemite o token.
  • Um middleware de log ou tracing que “normaliza” cabeçalhos e reescreve o valor de Authorization.
  • Um desenvolvedor que formatou o token para conseguir lê-lo e depois colou de volta a versão formatada.

Se qualquer componente entre o seu assinante e o seu verificador puder reescrever o token, esse componente é o primeiro suspeito. Em trânsito, tokens são strings opacas: as únicas operações seguras são guardar, copiar e comparar.

3. Mesmo segredo, bytes diferentes

É a causa por trás de todo relato de bug do tipo “o segredo é literalmente idêntico, eu fiz o diff”, e quase nenhum guia de troubleshooting a cobre.

HMAC não consome uma string. Consome bytes. Seu arquivo de configuração, seu gerenciador de segredos e suas variáveis de ambiente guardam strings. Alguma coisa precisa converter uma na outra, e essa conversão não é padronizada entre as bibliotecas JWT. Dois serviços podem guardar segredos idênticos caractere por caractere e mesmo assim calcular assinaturas diferentes.

Calculamos a prova localmente sobre o signing input da Seção 2. A string do segredo tem 36 caracteres:

c2VjcmV0LWtleS0xMjM0NTY3ODkwYWJjZGVm
Interpretação dos bytesBytesO que a chave realmente éAssinatura HS256 resultante
Tratada como texto UTF-836os próprios 36 caracteres visíveistUQobLFxHSIQqURPqGT59pkBnqQ95sZ0-JC1hE_4Zak
Decodificada de base64 antes27secret-key-1234567890abcdef53ISDuciq-ov8YF1Ezwy6zo6KUO-1tpwz2oW7gVPuIM

Mesma string de segredo. Mesmo algoritmo. Mesmo payload. Duas assinaturas sem nada em comum. O lado que “errou” reporta invalid signature, e nenhuma quantidade de diff no arquivo de configuração vai revelar coisa alguma, porque os arquivos de configuração são iguais.

O token completo para a leitura UTF-8, caso você queira reproduzir:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.tUQobLFxHSIQqURPqGT59pkBnqQ95sZ0-JC1hE_4Zak

Cole-o no decodificador JWT com o segredo acima e a verificação passa. Decodifique o segredo como base64 antes e ela não passa.

Como cada biblioteca transforma uma string em bytes de chave

Fique com o que está documentado. A tabela é curta de propósito, e a última coluna importa mais do que a primeira.

Runtime / bibliotecaConversão de string para bytesQuem decide
Node jsonwebtokenbytes UTF-8 da stringa biblioteca
Python PyJWTbytes UTF-8 da stringa biblioteca
Java jjwt, sobrecarga String legadacodec base64 da plataforma, conforme jwtk/jjwt#204a biblioteca
Go golang-jwtrecebe []byte diretamentevocê, no ponto de chamada
.NETrecebe byte[] diretamentevocê, no ponto de chamada

A linha do Java é a origem histórica da dor entre stacks, e o detalhe importa. Em versões antigas do jjwt, signWith(SignatureAlgorithm, String) e suas irmãs passavam a String por um codec base64 em vez de pegar seus bytes brutos, enquanto as sobrecargas com byte[] usavam os bytes exatamente como recebidos. Um serviço Node e um serviço Java compartilhando um mesmo segredo, portanto, discordavam. Essa API com String está depreciada desde o jjwt 0.10, e a forma moderna é explícita:

SecretKey key = Keys.hmacShaKeyFor(secretBytes);

Isto não é “como o Java faz JWTs”. É a sobrecarga legada de uma biblioteca específica, e o código jjwt atual que passa um byte[] não tem ambiguidade nenhuma. O relato espelhado do lado do Node é auth0/node-jsonwebtoken#208, em que tokens assinados em Java não verificavam no Node. Existem relatos parecidos contra o firebase/php-jwt do PHP (veja firebase/php-jwt#153), embora nós mesmos não tenhamos verificado como aquela biblioteca trata os bytes, então encare isso como uma pista, não como um diagnóstico.

Go e .NET pertencem a outra categoria. Nenhuma das duas bibliotecas decide por você; ambas entregam o parâmetro []byte / byte[] e se afastam. []byte(secret) e Encoding.UTF8.GetBytes(secret) produzem UTF-8, enquanto Convert.FromBase64String(secret) produz bytes decodificados. O bug, quando acontece, mora no seu ponto de chamada. Isso é uma boa notícia: ele está visível no seu próprio diff.

Meu segredo JWT é base64 ou UTF-8?

Não existe nenhum campo no token que responda isso. Você precisa raciocinar sobre a própria string:

  1. Ela usa apenas A–Z a–z 0–9 + / = (ou - e _)? Se sim, ela pode ser base64. Um segredo que contenha um espaço, um ! ou um # não pode ser.
  2. O comprimento dela é múltiplo de 4, ou ela termina com o padding =? As duas coisas são fortes indícios de que algo a codificou em base64 no caminho.
  3. Decodificá-la de base64 produz bytes que fazem sentido? Passe-a pelo decodificador Base64. ASCII legível ou exatamente 32 bytes de aparência aleatória sugerem base64. Mojibake sugere que a string nunca foi codificada.

Um segredo como c2VjcmV0LWtleS0xMjM0NTY3ODkwYWJjZGVm passa nos três testes, e é exatamente por isso que ele é perigoso: é ambíguo, e as duas leituras são plausíveis. Segredos que contêm - ou _ são ambíguos de um jeito ainda pior, já que são base64url válido mas base64 padrão inválido.

Quando o raciocínio não chega a uma resposta, calcule os dois. Pegue o signing input e rode HMAC-SHA256 sobre ele duas vezes no gerador de HMAC, uma vez com o segredo como texto e outra com os bytes decodificados. Depois compare cada resultado com o terceiro segmento do token. Um deles vai bater, e é isso que diz qual lado do seu sistema está certo.

Caracteres não são bytes

A armadilha aparentada é contar caracteres quando o requisito está em bytes. A RFC 7518 §3.2 enuncia o piso da chave para HMAC-SHA em bits, não em caracteres, e texto codificado se expande:

Como você escreveEntropiaBytes equivalentesPara HS256 (precisa de ≥256 bit)
32 caracteres hex128 bit16 bytes❌ abaixo do piso
32 caracteres base64192 bit24 bytes❌ abaixo do piso
32 bytes aleatórios256 bit32 bytes✅ atende (64 caracteres em hex, 44 em base64 com padding)

Um “segredo de 32 caracteres” pode ter de 128 a 256 bits, dependendo do alfabeto. Isso é ortogonal ao problema de interpretação de bytes acima, mas morde as mesmas pessoas, porque um time que mede em caracteres costuma ser um time que nunca olhou os bytes. Para as regras de escolha propriamente ditas (comprimento, codificação, rotação), vá ao gerador de segredo JWT; as notas de referência dele cobrem o assunto direito e não há motivo para duplicá-las aqui.

4. O próprio segredo foi contaminado

Seus dois serviços concordam sobre a interpretação dos bytes. A assinatura continua falhando. Agora verifique se o segredo que cada lado carregou é o segredo que você acha que escreveu, porque o encanamento de ambiente é notavelmente bom em acrescentar um byte.

Quebra de linha no fim do .env. JWT_SECRET=abc seguido de uma quebra de linha pode ser carregado como abc\n por alguns leitores. Um byte a mais, e o HMAC produz uma saída completamente sem relação. Não existe semelhança parcial que dê para notar.

Aspas lidas como dado. JWT_SECRET="abc" significa abc para alguns carregadores e "abc" para outros, principalmente quando o arquivo é lido por um shell em vez de interpretado por uma biblioteca. O env_file do Docker Compose e um parser de .env podem discordar sobre o mesmo arquivo.

Caracteres invisíveis vindos do copiar-e-colar. Copiar um segredo do Slack, de um wiki ou de um PDF pode arrastar junto um espaço de largura zero (U+200B, bytes e2 80 8b) ou um espaço não separável (U+00A0, bytes c2 a0). Os dois são invisíveis em qualquer editor e os dois mudam o HMAC.

Deformações em CI e contêineres. Segredos que passam por interpolação de shell têm o $ expandido ou as barras invertidas comidas. Alguns sistemas de CI aparam os valores, outros não. Segredos do Kubernetes são base64 no manifesto e brutos dentro do contêiner, uma armadilha de dupla decodificação por si só.

A correção é parar de olhar o segredo e começar a medi-lo. De cada lado, imprima o comprimento e uma impressão digital, nunca o valor:

printf '%s' "$JWT_SECRET" | wc -c
printf '%s' "$JWT_SECRET" | shasum -a 256 | cut -c1-16

Rode os dois comandos no assinante e no verificador e compare as duas saídas. Comprimento igual e impressão digital igual significam que o segredo não é o seu problema; volte para a Seção 3. Um comprimento uma unidade maior do que você espera é a quebra de linha final. Duas unidades maior são as aspas.

Quando o comprimento estiver errado e você quiser ver exatamente o que há ali dentro, faça um hex dump num shell local, usando um segredo de desenvolvimento:

printf '%s' "$JWT_SECRET" | xxd

Um 0a no fim é uma quebra de linha. Um 22 no começo e outro no fim são um par de aspas. c2 a0 ou e2 80 8b no meio é o caso do caractere invisível. Não rode isso contra um segredo de produção em uma máquina que mande a saída do terminal para algum lugar.

A verificação equivalente dentro de um processo Node ou Python em execução:

const s = process.env.JWT_SECRET ?? '';
console.log(Buffer.byteLength(s, 'utf8'), JSON.stringify(s.slice(-3)));
import os
s = os.environ["JWT_SECRET"]
print(len(s), len(s.encode("utf-8")), repr(s[-3:]))

Em Python, len(s) contando menos que len(s.encode("utf-8")) diz que há caracteres não ASCII em um segredo que deveria ser ASCII.

5. Algoritmo e tipo de chave não combinam

O cabeçalho alg e a chave que você passa precisam pertencer à mesma família. HS256 quer um segredo compartilhado, que é uma sequência de bytes. RS256 e ES256 querem uma chave assimétrica, que é um PEM ou um JWK. Troque os fios e você recebe falhas que vão de um erro de tipo bem claro até um seco invalid signature, dependendo do quanto a biblioteca é tolerante.

Versões comuns disso:

  • O cabeçalho diz HS256 e o verificador entrega à biblioteca uma chave pública PEM. Algumas bibliotecas calculam o HMAC sobre o texto do PEM e reportam divergência de assinatura.
  • O cabeçalho diz RS256 e o verificador entrega a string do segredo HMAC.
  • O verificador não passa lista de algoritmos nenhuma e deixa a biblioteca inferir a partir do alg, de modo que um desvio de configuração no lado que assina muda silenciosamente o que o verificador faz.

O último é o ponto em que um bug de configuração vira um bug de segurança, então fixe o algoritmo explicitamente em toda chamada de verificação:

jwt.verify(token, key, { algorithms: ['HS256'] });
jwt.decode(token, key, algorithms=["HS256"])

Fixar também converte erros vagos de assinatura em erros precisos. Se um token chega com alg: RS256 e a sua allowlist diz HS256, você recebe um erro explícito de algoritmo citando os dois valores.

Repare que tudo nesta seção é erro de configuração: dois componentes seus discordando, sem nenhum adversário envolvido. Existe uma falha aparentada, de formato idêntico, em que um atacante reescreve o alg de RS256 para HS256 e assina usando a sua chave pública como segredo HMAC. Isso é confusão de algoritmos, é um ataque e não um bug, e está coberto em boas práticas de segurança JWT junto com o resto do modelo de ameaças. A defesa (uma allowlist explícita) por acaso é a mesma, o que é um bom argumento para aplicá-la mesmo quando você está só caçando um bug.

6. O token mudou em trânsito

Antes de culpar as chaves, confirme que o verificador recebeu a mesma string que o assinante produziu. Um JWT é frágil exatamente nos pontos em que strings são frágeis.

O prefixo Bearer. Authorization: Bearer eyJhbGci... é um valor de cabeçalho, não um token. Dividir pelo separador errado, ou dividir uma vez e ficar com a metade errada, deixa você verificando Bearer eyJhbGci... ou uma string vazia. Remova o prefixo de propósito:

const token = req.headers.authorization?.replace(/^Bearer\s+/i, '').trim();

Espaços em branco e quebras de linha. Tokens copiados de um terminal vêm quebrados em várias linhas. Tokens guardados em YAML sofrem dobra de linha. Um único \n embutido no terceiro segmento produz divergência de assinatura, não erro de parse, porque decodificadores base64url costumam ignorar espaços em branco, enquanto a comparação de strings não ignora.

Codificação de URL. Um token que viajou como parâmetro de query pode voltar com . virado %2E, ou com - e _ traduzidos por um codificador afobado. Decodifique uma vez, exatamente uma vez.

Truncamento. Cookies têm um teto de cerca de 4 KB cada, e tokens RS256 com alguns claims passam disso rotineiramente. Um token truncado normalmente falha na decodificação base64, mas se o corte cair numa fronteira de 4 caracteres você fica com um token de aparência válida e assinatura errada.

Dois comandos resolvem a questão. Um JWT bem formado tem exatamente dois pontos:

printf '%s' "$TOKEN" | tr -cd '.' | wc -c

E todo caractere precisa estar no alfabeto base64url, então isto não deve imprimir absolutamente nada:

printf '%s' "$TOKEN" | tr -d 'A-Za-z0-9._-' | xxd

Qualquer saída do segundo comando dá nome ao seu problema: 3d é o padding = que não deveria estar ali, 2b ou 2f são o + e o / do base64 padrão onde base64url espera - e _, e 20 é um espaço perdido.

7. Falhas específicas de RS256 e ES256

Algoritmos assimétricos trocam o problema do segredo por um problema de gerenciamento de chaves. Os modos de falha são diferentes o bastante para merecer uma lista própria.

PKCS#1 versus PKCS#8. São dois formatos de contêiner para a mesma chave RSA, e dá para distingui-los visualmente por uma palavra na linha de cabeçalho:

-----BEGIN RSA PRIVATE KEY-----      ← PKCS#1
-----BEGIN PRIVATE KEY-----          ← PKCS#8

As bibliotecas variam em qual delas aceitam. Quando uma rejeita o formato de cara, você recebe um erro claro; quando ela faz um parse pela metade, você pode acabar com uma assinatura que nunca verifica. Converta em vez de brigar:

openssl pkcs8 -topk8 -nocrypt -in pkcs1.pem -out pkcs8.pem

As chaves estão trocadas. Assinar com a chave pública, ou verificar com a privada. Óbvio em princípio, fácil de fazer quando os dois arquivos moram no mesmo diretório com nomes que diferem por quatro caracteres. Confirme qual é qual:

openssl rsa -in key.pem -noout -text | head -1

Uma chave privada imprime o tamanho do módulo como chave privada; uma chave pública dá erro a menos que você acrescente -pubin.

Desvio entre JWKS e kid. Com um endpoint JWKS, o verificador escolhe uma chave casando o kid do token com o conjunto de chaves. Três coisas dão errado aqui: o assinante rotacionou e o JWKS em cache do verificador está velho; o token não tem kid e o verificador pega a primeira chave do conjunto; ou dois ambientes publicam valores de kid que se sobrepõem. Quando suspeitar disso, busque o JWKS de novo e confirme que o kid exato do cabeçalho do token está lá dentro.

Codificação da assinatura ES256. Assinaturas ECDSA são um par de inteiros, r e s, e há duas maneiras de serializá-los. Stacks criptográficas de propósito geral costumam emitir DER, uma estrutura ASN.1 de comprimento variável. A RFC 7518 §3.4 exige, em vez disso, a forma JOSE: r e s preenchidos cada um até um comprimento fixo e concatenados, o que dá 64 bytes para P-256. Uma assinatura DER jogada dentro de um JWT tem um comprimento diferente do que o formato espera. Ou seja, um token ES256 cujo terceiro segmento não decodifica para exatamente 64 bytes foi construído por algo que pulou a conversão.

Para isolar se o problema é a sua chave ou o seu pipeline, assine o mesmo payload de forma independente no codificador JWT e compare a saída com o que o seu serviço produziu. Assinaturas idênticas apontam para o transporte ou para o tratamento dos claims. Assinaturas diferentes apontam para a chave.

8. Erros que parecem falhas de assinatura, mas não são

Algumas dessas falhas são mal rotuladas pelas próprias bibliotecas, e é assim que acabam no relatório de bug errado.

SintomaO que é de verdadeOnde procurar
PyJWT ExpiredSignatureErrorexp está no passado. O nome fala em assinatura; a causa é um claim.Diferença de relógio entre os hosts, ou um TTL curto demais
PyJWT ImmatureSignatureErrornbf está no futuroO relógio do assinante está adiantado em relação ao do verificador
Node TokenExpiredErrorexp está no passadoO mesmo caso acima
401 genérico, sem detalheO framework colapsou toda falha de verificação em uma única respostaLigue o log de erros no nível da biblioteca
Funciona por alguns minutos, depois falhaExpiração do token, não assinaturaCompare iat e exp com os relógios dos dois hosts
Falha só para um audienceDivergência de aud ou issA lista de audiences esperada pelo verificador

A nomenclatura do PyJWT é a armadilha mais notória. ExpiredSignatureError contém a palavra “signature”, mas é lançado durante a validação de claims, muito depois de a assinatura já ter sido verificada com sucesso. Buscar essa string de erro leva direto a material de troubleshooting de assinatura, e horas somem na parte errada do problema.

A diferença de relógio produz o padrão mais confuso de todos: falhas intermitentes que não se correlacionam com nada no seu código. Se o relógio de um host adianta, tokens recém-emitidos falham na validação de nbf ou iat assim que chegam, e as falhas ficam vagando conforme o desvio cresce. Compare date -u nas duas máquinas primeiro. A maioria das bibliotecas aceita um parâmetro de tolerância (leeway), que é a correção certa para um desvio que você não consegue eliminar e a correção errada para um relógio quebrado.

A regra geral: se a falha depende do tempo, do host ou do audience, não é problema de assinatura. Falhas de assinatura são determinísticas. O mesmo token com a mesma chave falha do mesmo jeito para sempre.

9. Um fluxo de troubleshooting repetível

Execute na ordem. Cada passo ou encontra o bug ou elimina um ramo, e parar cedo é justamente o objetivo.

  1. Decodifique o cabeçalho. Cole o token no decodificador JWT e anote alg e kid. Isso decide tudo o que vem depois e não precisa de chave.
  2. Confira o formato do token. Exatamente dois pontos, só caracteres base64url, sem o prefixo Bearer, sem espaços. Use os dois comandos da Seção 6. Elimina corrupção no transporte.
  3. Fixe o algoritmo na chamada de verificação. Se houver divergência entre o alg e a sua allowlist, agora você recebe um erro explícito citando os dois em vez de um erro genérico.
  4. Tire a impressão digital da chave dos dois lados. Imprima o comprimento em bytes e um SHA-256 truncado no assinante e no verificador, como na Seção 4. Valores diferentes significam que a culpa é do encanamento, e você nem chega ao passo 5.
  5. Se os dois lados são linguagens diferentes, resolva a interpretação dos bytes. Consulte a tabela da Seção 3, decida explicitamente se o segredo é texto ou base64 e faça os dois lados declararem isso no código, em vez de deixar por conta do padrão.
  6. Reassine o mesmo payload de forma independente. Use o codificador JWT com a chave que você acredita estar correta e compare o terceiro segmento dele com o do seu token. Se baterem, o seu lado de assinatura está bem e o problema é o verificador.
  7. Confira o HMAC na mão. Passe o signing input pelo gerador de HMAC com as duas interpretações de bytes. A que bater com o token diz qual lado mudar.

Se você passar pelos sete e ainda precisar de ajuda, escreva um bom relato de bug. A maioria deles trava porque omite justamente os fatos que determinam a resposta. Inclua estes:

  • O valor de alg no cabeçalho e se há um kid presente
  • Linguagem, biblioteca e versão exata dos dois lados, o que assina e o que verifica
  • O comprimento em bytes do segredo dos dois lados e os primeiros 16 caracteres hex do SHA-256 dele (nunca o segredo em si)
  • Se o segredo é guardado como texto ou como base64, e como cada lado o converte
  • O signing input completo. Os dois primeiros segmentos não são sensíveis; quem tem o token consegue lê-los de qualquer forma
  • Para RS256 e ES256: a linha de cabeçalho do PEM, literalmente

Essa lista transforma um irrespondível “my JWT signature does not match” em uma pergunta que alguém consegue de fato resolver, normalmente em uma única resposta.

FAQ

Por que o mesmo segredo funciona em uma linguagem e falha em outra?

Porque as bibliotecas discordam sobre como transformar a string do segredo em bytes de chave. Node jsonwebtoken e Python PyJWT usam UTF-8; a sobrecarga legada com String do jjwt usava um codec base64 (jwtk/jjwt#204); Go e .NET deixam a decisão para o seu ponto de chamada. Mesmos caracteres, bytes diferentes, HMAC diferente.

A assinatura cobre o payload decodificado ou a string codificada?

A string codificada. A RFC 7515 define o signing input como base64url(header) + "." + base64url(payload) em ASCII literal. Qualquer camada que desserialize o payload e o serialize de novo muda a ordem das chaves, os espaços em branco ou a formatação dos números. A string fica diferente, e a assinatura também.

Meu segredo parece base64 — devo decodificá-lo antes de assinar?

Só se o outro lado também decodificar. Não existe resposta certa isoladamente; o requisito é que as duas pontas concordem. Verifique se a string usa apenas caracteres base64 e se o comprimento é múltiplo de quatro, e então deixe a escolha explícita no código dos dois lados, em vez de confiar nos padrões.

Uma quebra de linha no fim do .env pode mesmo quebrar a assinatura?

Pode. HMAC consome bytes, e abc\n são quatro bytes onde abc são três. A assinatura resultante não tem nada em comum com a correta. Imprima printf '%s' "$JWT_SECRET" | wc -c nos dois hosts; um comprimento uma unidade maior do que o esperado é quase sempre isso.

Como sei se o problema é o segredo ou o algoritmo?

Leia primeiro o alg do cabeçalho. Se começar com HS, você precisa de um segredo compartilhado e um PEM vai falhar. Se começar com RS, PS ou ES, você precisa de um par de chaves e uma string de segredo vai falhar. Uma vez que alg e o tipo de chave pertençam à mesma família, as falhas restantes são problemas de conteúdo da chave.

Por que o jwt.io diz que a assinatura é válida mas meu servidor rejeita?

Porque a ferramenta online e o seu servidor podem interpretar o segredo de formas diferentes: uma como texto UTF-8, o outro como base64. A ferramenta valida contra os bytes que ela derivou, não contra os bytes que o seu servidor derivou. E nunca cole segredos de produção em um site de terceiros; use uma chave de desenvolvimento.

O erro “invalid signature” pode ser causado por um token expirado?

Não. A verificação de assinatura roda antes da validação de claims, então a expiração nunca é a causa. A expiração aparece à parte, como TokenExpiredError no Node ou ExpiredSignatureError no PyJWT. O nome deste último engana, já que a assinatura foi verificada sem problema e só o exp falhou.

Conclusão

Divergências de assinatura quase nunca são um problema de criptografia. HMAC-SHA256 funciona. RSA funciona. O que falha é a fronteira em que uma string vira bytes: um codec base64 de um lado e UTF-8 do outro, uma quebra de linha que o carregador de configuração guardou, um payload que um gateway prestativamente reserializou. Toda causa deste guia é uma discordância sobre bytes.

Então torne os bytes explícitos e pare de depender de padrões. Registre, na documentação do seu time, se o segredo compartilhado é guardado como texto bruto ou como base64, e faça todo serviço convertê-lo do jeito declarado, em vez de herdar o que a biblioteca dele assumiu. Para sistemas que atravessam várias linguagens, guarde os segredos em hex ou em base64 e decodifique-os explicitamente em cada ponto de chamada: uma linha por serviço, e a ambiguidade acaba. Depois adicione a impressão digital de comprimento em bytes da Seção 4 ao seu health check, para que a próxima divergência apareça como um aviso na inicialização, e não como um 401 em produção.

Tags: jwt authentication debugging hmac api-security

Artigos relacionados

Ver todos os artigos