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
- Quito
BeareroAuthorization: Bearer. - Compruebo el número de segmentos.
- Convierto Base64URL a bytes correctamente.
- Decodifico esos bytes como UTF-8.
- Parseo JSON con errores explícitos.
- Inspecciono
expy 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
algykiddel 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
- Normalizo Bearer input.
- Verifico segmentos.
- Convierto Base64URL a bytes.
- Decodifico UTF-8.
- Parseo header y payload con errores explícitos.
- Reviso
alg,kid,iss,audy tiempo. - Multiplico NumericDate por 1000 al crear fechas JS.
- Etiqueto el resultado como decoded, no verified.
- Si la confianza importa, verifico con material de clave conocido.
- 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.