Guías JWT

Cómo decodifico un JWT en JavaScript sin confundir lectura con confianza

Mi flujo para decodificar JWT en JavaScript y Node.js manejando Bearer, Base64URL, UTF-8, JSON y exp correctamente, sin mezclar decoding con signature verification.

Por JWTDecoder.toolsÚltima verificación 2026-09-13

Voz editorial: la primera persona describe el flujo de trabajo documentado de JWTDecoder.tools; JWTDecoder.tools es el editor y autor organizacional.

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

Cuando necesito decodificar un JWT en JavaScript, fijo una regla antes de escribir código: decoding sirve para inspeccionar, no para autenticar. Que JSON.parse() funcione no demuestra que el token sea válido ni que pueda autorizar una petición.

Para una revisión rápida uso el JWT Decoder. Si necesito implementar el mismo comportamiento en una aplicación o script, sigo este orden.

Mi secuencia de 6 pasos

  1. Quito Bearer o Authorization: Bearer.
  2. Compruebo el número de segmentos.
  3. Convierto Base64URL a bytes correctamente.
  4. Decodifico esos bytes como UTF-8.
  5. Parseo JSON con errores explícitos.
  6. Inspecciono exp y otros claims, pero dejo la firma para un paso separado.

Paso 1: normalizo la entrada

En debugging puedo copiar:

Bearer eyJ...

O:

Authorization: Bearer eyJ...

Uso algo simple:

function normalizeJwtInput(input) {
  return input
    .trim()
    .replace(/^authorization\s*:\s*bearer\s+/i, "")
    .replace(/^bearer\s+/i, "")
    .trim()
    .replace(/^['"]|['"]$/g, "");
}

Quiero que el resto del decoder reciba siempre una sola forma: el compact token.

Paso 2: cuento segmentos antes de decodificar

Un JWS firmado suele tener:

header.payload.signature

Un JWE compacto suele tener cinco segmentos.

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

  if (parts.length === 5) {
    throw new Error("Parece un JWE cifrado, no un JWT firmado de 3 partes");
  }
  if (parts.length !== 3) {
    throw new Error(`Esperaba 3 segmentos, recibí ${parts.length}`);
  }

  return parts;
}

Prefiero esto a acceder ciegamente a token.split('.')[1].

Paso 3: trato Base64URL como Base64URL

JWT usa el alfabeto URL-safe, reemplaza + y / por - y _, y suele omitir padding.

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, c => c.charCodeAt(0));
}

atob() me da bytes representados como binary string; no resuelve UTF-8 por sí solo.

Paso 4: decodifico UTF-8 antes de JSON

Con ASCII un decoder incompleto puede parecer correcto durante meses. El problema aparece cuando un claim contiene nombres o texto no ASCII.

function decodeJwtJson(segment, label) {
  let text;
  try {
    const bytes = base64UrlToBytes(segment);
    text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
  } catch {
    throw new Error(`${label} no es Base64URL/UTF-8 válido`);
  }

  let value;
  try {
    value = JSON.parse(text);
  } catch {
    throw new Error(`${label} no contiene JSON válido`);
  }

  if (!value || typeof value !== "object" || Array.isArray(value)) {
    throw new Error(`${label} debe ser un objeto JSON`);
  }
  return value;
}

Separar error de encoding y error de JSON me ayuda a localizar el fallo real.

Paso 5: monto el decoder mínimo

function decodeJwt(input) {
  const token = normalizeJwtInput(input);
  const parts = token.split(".");
  if (parts.length !== 3) throw new Error("Esperaba un JWT firmado de 3 partes");

  return {
    token,
    header: decodeJwtJson(parts[0], "JWT header"),
    payload: decodeJwtJson(parts[1], "JWT payload"),
    signature: parts[2]
  };
}

Ahora puedo leer alg, kid, iss, aud y exp. Todavía no he verificado la firma.

Paso 6: convierto exp usando las unidades correctas

NumericDate usa segundos; Date.now() usa milisegundos.

function getExpiration(payload) {
  if (typeof payload.exp !== "number") return { hasExpiration: false };

  return {
    hasExpiration: true,
    expiresAt: new Date(payload.exp * 1000),
    expired: Date.now() >= payload.exp * 1000
  };
}

No hago:

Date.now() >= payload.exp // unidades incorrectas

Para una revisión rápida de iat, nbf y exp uso JWT Expiry Checker.

En Node.js a veces uso Buffer

function decodeBase64UrlJson(segment) {
  return JSON.parse(Buffer.from(segment, "base64url").toString("utf8"));
}

function decodeJwtNode(token) {
  const parts = token.trim().split(".");
  if (parts.length !== 3) throw new Error("Esperaba un JWT firmado de 3 partes");
  return {
    header: decodeBase64UrlJson(parts[0]),
    payload: decodeBase64UrlJson(parts[1]),
    signature: parts[2]
  };
}

En un servidor que autentica peticiones no dejo esto como única protección; uso una biblioteca JOSE/JWT que verifique firma y policy.

Cuándo uso jwt-decode

Para client-side inspection puedo usar:

import { jwtDecode } from "jwt-decode";

const payload = jwtDecode(token);
const header = jwtDecode(token, { header: true });

La propia librería deja claro que decode no valida autenticidad. Por eso no la convierto en authentication gate del servidor.

Por qué evito JSON.parse(atob(token.split('.')[1]))

  • asume que existe payload;
  • confunde Base64URL con Base64 normal;
  • no maneja explícitamente padding;
  • puede romper UTF-8;
  • genera errores poco útiles;
  • ignora alg y kid del header;
  • hace fácil olvidar que la firma sigue sin verificar.

Después de decodificar todavía falta confiar

Si necesito saber si el token fue firmado por la entidad correcta, paso a JWT Signature Verifier o JWKS Validator.

Y después de una firma válida todavía compruebo iss, aud, exp, nbf, token type, scopes y roles con JWT Validator o JWT Debugger.

Mis reglas de privacidad en el navegador

  • no pongo JWT en URL;
  • no registro bearer tokens completos;
  • evito guardar tokens de debugging en storage persistente;
  • prefiero tokens sintéticos o redactados;
  • no envío un token a un backend solo para hacer Base64URL decode.

Mi checklist de JavaScript

  1. Normalizo Bearer input.
  2. Verifico segmentos.
  3. Convierto Base64URL a bytes.
  4. Decodifico UTF-8.
  5. Parseo header y payload con errores explícitos.
  6. Reviso alg, kid, iss, aud y tiempo.
  7. Multiplico NumericDate por 1000 al crear fechas JS.
  8. Etiqueto el resultado como decoded, no verified.
  9. Si la confianza importa, verifico con material de clave conocido.
  10. Si la aceptación importa, valido issuer, audience, tiempo, tipo y permisos.

El código para leer JWT no es la parte difícil. La decisión importante es mantener separados readable, cryptographically verified y acceptable for my application.

Fuentes y referencias

Guías relacionadas