Anatomia de um token
Aqui está um token HS256 completo, assinado com o segredo de demonstração demo-secret-do-not-use. As quebras de linha foram acrescentadas para facilitar a leitura; um token real fica numa linha só:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiJ1c2VyLTQyIiwibmFtZSI6IkFkYSIsImFkbWluIjpmYWxzZSwiaWF0IjoxNzYwMDAwMDAwLCJleHAiOjE3NjAwMDM2MDB9
.nKTVXOM_r1JbE92WsFd2SSun0a1TByUV8ZpqrRUnIhk
As três partes, separadas por .:
| Parte | Comprimento em Base64URL | Decodifica para |
|---|---|---|
| Header | 36 | {"alg":"HS256","typ":"JWT"} |
| Payload | 104 | {"sub":"user-42","name":"Ada","admin":false,"iat":1760000000,"exp":1760003600} |
| Assinatura | 43 | 32 bytes brutos de HMAC-SHA256 |
O header e o payload são JSON codificado em UTF-8 e depois em Base64URL. A assinatura é binária, então só faz sentido como bytes. iat e exp são timestamps Unix em segundos: este token foi emitido em 2025-10-09 08:53:20 UTC e expirou uma hora depois.
Por que Base64URL sem preenchimento
Um token passa por lugares onde +, / e = causam problemas: query strings (+ vira espaço), caminhos de URL (/ é separador), cookies e headers HTTP. Por isso o JWS, o formato de assinatura por trás do JWT, definido na RFC 7515, usa o alfabeto URL-safe da RFC 4648 e remove o preenchimento:
Base64 encoding using the URL- and filename-safe character set defined in Section 5 of RFC 4648, with all trailing ‘=’ characters omitted (as permitted by Section 3.2) and without the inclusion of any line breaks, whitespace, or other additional characters.
Em tradução livre: codificação Base64 com o conjunto de caracteres seguro para URLs e nomes de arquivo definido na seção 5 da RFC 4648, com todos os = do final omitidos (como a seção 3.2 permite) e sem quebras de linha, espaços em branco ou outros caracteres adicionais. Daí saem duas diferenças em relação ao Base64 padrão:
+vira-e/vira_. A assinatura acima contém_, que os decodificadores padrão rejeitam.- Nenhum
=no final. O comprimento de um segmento nunca é 4n+1, então um decodificador sempre consegue deduzir quanto preenchimento foi removido: resto 2 na divisão por 4 significa==, resto 3 significa=.
Mais sobre a diferença entre os alfabetos em Base64 vs Base64URL.
Decodificar as partes à mão
Divida nos pontos, restaure o alfabeto padrão e o preenchimento, decodifique e faça o parsing do JSON. No navegador:
function decodePart(part) {
const b64 = part.replace(/-/g, "+").replace(/_/g, "/");
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
const bytes = Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
return JSON.parse(new TextDecoder().decode(bytes));
}
const [header, payload] = token.split(".").slice(0, 2).map(decodePart);
Sem a etapa do TextDecoder, o texto fora do ASCII quebra. Um payload com {"name":"Zoë 日本"} vira eyJuYW1lIjoiWm_DqyDml6XmnKwifQ. Passe a string convertida só pelo atob e o nome sai como mojibake começando com Zoë, porque o atob retorna um caractere por byte. Nos runtimes que têm o recurso, Uint8Array.fromBase64(part, { alphabet: "base64url" }) substitui as três primeiras linhas; veja Uint8Array.toBase64. No Node.js, Buffer.from(part, "base64url") faz o mesmo.
Em Python, o urlsafe_b64decode cuida do alfabeto, mas ainda exige o preenchimento:
import base64, json
def b64url_decode(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
header, payload, signature = token.split(".")
print(json.loads(b64url_decode(header))) # {'alg': 'HS256', 'typ': 'JWT'}
print(json.loads(b64url_decode(payload))) # {'sub': 'user-42', 'name': 'Ada', ...}
No shell, traduza o alfabeto com tr e complete o preenchimento com um laço:
jwt_part() {
local s
s=$(printf '%s' "$1" | tr '_-' '/+')
while [ $(( ${#s} % 4 )) -ne 0 ]; do s="$s="; done
printf '%s' "$s" | base64 -d
}
jwt_part "$(echo "$TOKEN" | cut -d. -f2)"
Se você só quer ver o que há dentro de um token, cole-o no decodificador de JWT. Ele roda no seu navegador, então um token de produção não sai da sua máquina.
Por que o payload é legível
Codificação não é criptografia. O Base64URL só muda a forma como os bytes são escritos, e o decodificador de JWT desfaz isso sem chave nenhuma. Um JWT assinado garante que o payload não foi alterado desde a assinatura. Ele não esconde o payload.
A codificação também custa espaço. Cada claim cresce um terço depois de codificada em Base64URL, e o token inteiro viaja a cada requisição, num header ou cookie, então mantenha o payload pequeno: um ID e algumas claims curtas em vez de um perfil de usuário completo.
Trate toda claim como pública. IDs de usuário, papéis e horários de expiração não têm problema. Senhas, chaves de API e dados pessoais que quem tem o token não deveria ver, sim. Se o conteúdo precisa ser confidencial, use um JWT criptografado (JWE). A forma compacta dele tem cinco partes Base64URL: header, chave criptografada, IV, texto cifrado e tag de autenticação. A explicação codificação vs criptografia trata da diferença de forma geral.
Como a assinatura é calculada
A assinatura cobre o texto ASCII exato header.payload, as duas strings Base64URL com o ponto entre elas. Para HS256:
import hmac, hashlib
signing_input = f"{header}.{payload}".encode("ascii")
mac = hmac.new(b"demo-secret-do-not-use", signing_input, hashlib.sha256).digest()
hmac.compare_digest(mac, b64url_decode(signature)) # True
Os bytes brutos da assinatura são então codificados em Base64URL sem preenchimento. O tamanho deles depende do algoritmo:
| Algoritmo | Bytes da assinatura | Caracteres Base64URL |
|---|---|---|
| HS256 | 32 | 43 |
| ES256 | 64 | 86 |
| RS256 (chave de 2048 bits) | 256 | 342 |
As assinaturas ES256 no JWS são os valores brutos R || S, de 32 bytes cada. O OpenSSL e muitas bibliotecas de criptografia geram uma assinatura ECDSA codificada em DER, que começa com 30 e tem comprimento variável. Converter de uma forma para a outra é uma fonte comum de erros de “invalid signature”.
Para montar e assinar um token com chaves HS, RS, ES ou EdDSA, use o codificador de JWT. Para um HMAC bruto em Base64, veja a ferramenta HMAC.
Erros comuns
- Usar o
atobou um decodificador Base64 padrão diretamente. Funciona com alguns tokens e lança erro com outros, dependendo de aparecer ou não um-ou_. O payload do nosso exemplo decodifica comatob; a assinatura, não. - Acrescentar preenchimento ao criar tokens. Algumas bibliotecas rejeitam segmentos que terminam com
=. Remova-o. - Recodificar o header ou o payload antes de verificar. A assinatura cobre o texto Base64URL original. Fazer o parsing do JSON e serializá-lo de novo pode mudar a ordem das chaves ou os espaços, e a assinatura deixa de bater. Verifique sempre contra os segmentos como foram recebidos.
- Confiar num payload decodificado. Decodificar não é verificar. Confira a assinatura com o algoritmo esperado e nunca aceite
"alg":"none"(headereyJhbGciOiJub25lIiwidHlwIjoiSldUIn0), a menos que seja essa a intenção. - Usar a string do token como chave única. 32 bytes de assinatura precisam de 256 bits, e 43 caracteres carregam 258. Os 2 últimos bits não são usados, então no nosso exemplo o
kfinal pode ser trocado porl,moun, e decodificadores tolerantes ainda retornam os mesmos bytes. A assinatura continua válida. Isso não afeta a segurança, mas duas strings diferentes podem representar o mesmo token. - Ler timestamps como milissegundos.
expeiatestão em segundos. Multiplique por 1000 antes de passá-los aoDatedo JavaScript.
Perguntas frequentes
O payload do JWT é criptografado?
Não. Um JWT assinado comum (JWS) só codifica o payload em Base64URL, então qualquer pessoa que tenha o token consegue lê-lo. Tokens criptografados usam JWE, que tem cinco partes em vez de três.
Por que um JWT não termina com sinais de =?
A RFC 7515 define a codificação como Base64URL com todos os = do final removidos, para que os tokens possam ir em URLs e headers sem escape. Os decodificadores precisam recolocar o preenchimento ou aceitar entrada sem ele.
É preciso a chave secreta para decodificar as três partes?
Não. O header e o payload são Base64URL simples. A chave só é necessária para verificar a assinatura ou criar uma nova. O decodificador de JWT mostra as duas partes e confere a assinatura se você fornecer a chave.
Por que todo JWT começa com eyJ?
O header é um objeto JSON que começa com {", e esses dois bytes sempre viram eyJ. Mais prefixos como esse estão listados em prefixos Base64 comuns.