Guias JWT

Como Decodificar JWT em JavaScript

Veja como decodificar JWT em JavaScript com Base64URL e UTF-8, tratar Bearer tokens, ler exp e evitar confundir decodificação com verificação.

Por JWTDecoder.toolsÚltima verificação em 2026-09-12

Voz editorial: a primeira pessoa descreve o fluxo de trabalho documentado do JWTDecoder.tools; JWTDecoder.tools é o editor e autor organizacional.

JWT → Base64URL → UTF-8 → JSON
JWT → Base64URL → UTF-8 → JSON · decode ≠ verify

Quando preciso decodificar JWT em JavaScript, tento manter o código pequeno, mas não tão pequeno a ponto de esconder os erros importantes. Dividir a string por ponto e chamar atob() pode funcionar em exemplos ASCII simples; para uma implementação de depuração mais confiável, também preciso tratar Base64URL, UTF-8, prefixos Bearer e JSON inválido.

Minha primeira regra continua a mesma: este código serve para ler cabeçalho e payload. Ele não verifica a assinatura. Se eu preciso provar autenticidade, passo para uma biblioteca/implementação de verificação com uma chave que a aplicação realmente confia.

Para uma inspeção rápida sem escrever código, uso o Decodificador JWT. Abaixo está o raciocínio que aplico quando quero fazer o mesmo em JavaScript.

A estrutura que espero receber

Um JWT/JWS assinado normalmente tem:

header.payload.signature

Cada um dos dois primeiros segmentos é Base64URL de bytes UTF-8 que normalmente representam JSON.

Por isso, meu fluxo é:

entrada
  ↓
remover Authorization: Bearer / Bearer
  ↓
confirmar 3 segmentos
  ↓
Base64URL → bytes
  ↓
UTF-8 → texto
  ↓
JSON.parse

Se a entrada tiver cinco segmentos, considero a possibilidade de JWE e não tento fingir que o segundo segmento é um payload JWT legível.

Primeiro: normalizo o Bearer token

Muitas vezes o valor vem direto de um header HTTP:

Authorization: Bearer eyJhbGciOi...

ou apenas:

Bearer eyJhbGciOi...

Antes de dividir por ponto, removo esses prefixos:

function normalizeJwtInput(input) {
  return input
    .trim()
    .replace(/^Authorization:\s*Bearer\s+/i, "")
    .replace(/^Bearer\s+/i, "")
    .trim();
}

Isso evita um diagnóstico falso de “Base64 inválido” causado apenas pelo texto Bearer no começo da string.

Base64URL não é o mesmo texto que Base64 tradicional

JWT usa Base64URL. A diferença mais visível é:

- no lugar de +
_ no lugar de /
padding = normalmente omitido

Por isso normalizo antes de atob():

function base64UrlToBytes(segment) {
  const base64 = segment
    .replace(/-/g, "+")
    .replace(/_/g, "/");

  const padded = base64.padEnd(
    base64.length + ((4 - (base64.length % 4)) % 4),
    "="
  );

  const binary = atob(padded);
  return Uint8Array.from(binary, (char) => char.charCodeAt(0));
}

Usar atob(segment) diretamente costuma funcionar apenas enquanto os caracteres e o padding não expõem a diferença.

Depois converto os bytes em UTF-8

Este ponto importa para payloads que contêm nomes, acentos, emoji ou outros caracteres fora de ASCII.

Uso TextDecoder:

function decodeBase64UrlUtf8(segment) {
  const bytes = base64UrlToBytes(segment);
  return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
}

Depois faço o parse:

function decodeJsonSegment(segment) {
  return JSON.parse(decodeBase64UrlUtf8(segment));
}

Se o texto não for UTF-8 válido ou não for JSON válido, prefiro receber um erro explícito em vez de continuar com dados corrompidos.

Uma função completa para decodificar o JWT

Uma versão pequena que uso como referência de leitura é:

function decodeJwt(input) {
  const token = normalizeJwtInput(input);
  const parts = token.split(".");

  if (parts.length === 5) {
    throw new Error("Parece um JWE criptografado de 5 segmentos.");
  }

  if (parts.length !== 3) {
    throw new Error("Um JWT assinado deve ter 3 segmentos.");
  }

  const [headerSegment, payloadSegment, signature] = parts;

  if (!headerSegment || !payloadSegment) {
    throw new Error("Cabeçalho ou payload ausente.");
  }

  return {
    header: decodeJsonSegment(headerSegment),
    payload: decodeJsonSegment(payloadSegment),
    signature
  };
}

Uso isso para inspeção e depuração. Não chamaria essa função de verifyJwt, porque ela não faz nenhuma operação criptográfica.

Como leio o claim exp em JavaScript

Um erro muito comum é misturar segundos e milissegundos.

JWT NumericDate usa segundos desde o Unix epoch. Date.now() retorna milissegundos.

Então:

const expiresAt = payload.exp * 1000;
const isExpired = Date.now() >= expiresAt;

E para exibir uma data:

const expiration = new Date(payload.exp * 1000);
console.log(expiration.toISOString());

Nunca comparo diretamente um exp típico de 10 dígitos com Date.now() sem ajustar a unidade.

Quando só quero o resultado e não o código, uso o Verificador de Expiração JWT, que também mostra iat e nbf.

Como trato claims ausentes

Não escrevo código supondo que todo JWT terá exp, iss, aud ou kid.

Por exemplo:

if (typeof payload.exp === "number") {
  const expired = Date.now() >= payload.exp * 1000;
}

Uma claim ausente pode ser inválida para o perfil da sua aplicação, mas isso é uma decisão de validação, não uma falha automática de decodificação.

É por isso que mantenho leitura e política separadas.

Como lido com aud sendo string ou array

A RFC 7519 permite que aud apareça como uma string ou, em casos gerais, como uma lista de strings.

Eu normalizo para um array quando preciso comparar:

function audienceValues(aud) {
  if (typeof aud === "string") return [aud];
  if (Array.isArray(aud)) {
    return aud.filter((value) => typeof value === "string");
  }
  return [];
}

Isso evita validadores caseiros que aceitam apenas um dos formatos e geram um audience mismatch falso.

Browser versus Node.js

No navegador, atob() e TextDecoder estão disponíveis nas plataformas modernas.

No Node.js, posso usar Buffer:

const json = Buffer
  .from(payloadSegment, "base64url")
  .toString("utf8");

const payload = JSON.parse(json);

Mesmo assim, mantenho a mesma separação mental: Buffer.from(..., "base64url") decodifica; não verifica a assinatura.

Se estou implementando autenticação real no backend, prefiro uma biblioteca JOSE/JWT madura que execute verificação, restrição de algoritmo e validação de claims, em vez de montar criptografia manualmente.

Por que não uso JSON.parse(atob(payload)) como solução final

Este one-liner aparece muito em snippets:

JSON.parse(atob(token.split(".")[1]));

Ele é atraente, mas esconde várias suposições:

  • o token tem exatamente a forma esperada;
  • não veio com Bearer;
  • o segmento é compatível com Base64 tradicional;
  • padding não será problema;
  • o JSON contém apenas caracteres simples;
  • o payload é JSON válido;
  • não é um JWE;
  • ninguém interpretará “parseou” como “assinatura válida”.

Para um console rápido em um token controlado, pode servir. Para uma ferramenta ou utilitário reutilizável, considero frágil demais.

Como verifico a assinatura depois de decodificar

Depois de ler:

{
  "alg": "RS256",
  "kid": "key-42"
}

não importo automaticamente uma chave indicada pelo próprio token e confio nela.

Primeiro identifico qual emissor a aplicação confia e qual é a fonte confiável de chaves. Depois seleciono a chave apropriada e verifico a assinatura sobre os bytes originais de:

base64url(header).base64url(payload)

Se tenho uma chave PEM/secret, uso o Verificador de Assinatura JWT. Se o emissor usa JWKS, uso o Validador JWKS.

Erros que quero mostrar claramente

Em uma ferramenta de desenvolvimento, tento diferenciar:

  • entrada vazia;
  • quantidade de segmentos errada;
  • JWE de cinco segmentos;
  • Base64URL inválido;
  • bytes que não são UTF-8 válido;
  • cabeçalho sem JSON válido;
  • payload sem JSON válido;
  • JSON que não é objeto.

Misturar tudo em “invalid token” torna a depuração muito mais lenta.

Foi por isso que o JWTDecoder.tools passou a usar códigos internos estáveis de erro e só depois transformar cada um em uma mensagem localizada para o usuário.

Meu checklist para decodificar JWT em JavaScript

Quando escrevo meu próprio decoder de inspeção, verifico:

  1. Remoção segura de Authorization: Bearer e Bearer.
  2. Distinção entre JWT/JWS de 3 segmentos e JWE de 5.
  3. Conversão Base64URL correta, inclusive padding.
  4. Conversão dos bytes com UTF-8.
  5. JSON.parse com tratamento de erro.
  6. exp, iat e nbf tratados como segundos.
  7. aud aceitando string e array.
  8. Nenhuma mensagem sugerindo que decodificação equivale a assinatura válida.

Se a finalidade é autenticação de produção, o passo seguinte não é adicionar mais snippets ao decoder. É usar uma implementação de verificação confiável, com algoritmo permitido, chave ligada ao emissor e validação explícita de iss, aud, exp e demais regras do sistema.

Para mim, essa é a fronteira correta: JavaScript consegue decodificar JWT com poucas linhas; autenticar um JWT exige muito mais do que decodificar Base64URL.

Fontes e referências

Guias relacionados