JWT Decoder JavaScript — atob(), TextDecoder y jose

·JavaScript Performance Engineer·Revisado porSophie Laurent·Publicado

Usa el Decodificador JWT gratuito directamente en tu navegador — sin instalación.

Probar Decodificador JWT online →

Todo flujo de autenticación que he construido llega eventualmente al mismo punto: tienes un JWT guardado en una cookie, un encabezado o una URL de callback de OAuth, y necesitas leer qué hay dentro. Un decodificador de JWT en JavaScript no requiere ningún paquete npm. El encabezado y el payload del token son simplemente JSON codificado en Base64url, y tanto el navegador como Node.js traen todo lo necesario para decodificarlos. Esta guía cubre el pipeline completo del decodificador de texto JavaScript para JWTs: dividir el token, normalizar base64url a Base64 estándar, atob() y TextDecoder para el manejo correcto de UTF-8, el Buffer.from() de Node.js, la verificación de firma con jose, y los errores comunes que afectan a los desarrolladores a diario. Para una inspección rápida puntual, prueba el decodificador JWT online en su lugar. Todos los ejemplos están orientados a ES2020+ y Node.js 18+.

  • Divide el JWT por "." — el índice 0 es el encabezado, el índice 1 es el payload, el índice 2 es la firma.
  • atob() decodifica Base64 pero devuelve Latin-1, no UTF-8. Usa TextDecoder o Buffer.from() para claims no ASCII.
  • Buffer.from(segment, "base64url") maneja base64url de forma nativa en Node.js — no se necesita reemplazo manual de caracteres.
  • Decodificar NO es verificar. Nunca confíes en claims de un JWT decodificado sin comprobar la firma en el servidor.
  • La librería jose hace ambas cosas: verifica firmas HS256/RS256/ES256 y devuelve el payload decodificado en una sola llamada.

¿Qué es la decodificación de JWT?

Un JSON Web Token está formado por tres segmentos codificados en Base64url separados por puntos. El primer segmento es el encabezado, el segundo es el payload (los claims que realmente te interesan) y el tercero es la firma criptográfica. El encabezado es un pequeño objeto JSON que describe el token en sí. Su campo más importante es alg — el algoritmo de firma (p. ej., HS256, RS256, ES256). El campo typ es casi siempre "JWT", y el campo opcional kid identifica qué clave se usó para firmar el token — fundamental cuando un proveedor de identidad rota las claves y publica un endpoint JWKS con múltiples claves públicas.

El payload lleva los claims. El RFC 7519 define siete nombres de claim registrados: sub (sujeto — normalmente el ID de usuario), iss (emisor — la URL del servidor de autenticación), aud (audiencia — la API para la que está destinado el token), iat (marca de tiempo de emisión), exp (marca de tiempo de expiración), nbf (marca de tiempo de no-antes-de), y jti (ID del JWT — utilizado para prevenir ataques de repetición). Todas las marcas de tiempo son segundos epoch Unix, no milisegundos. El segmento de firma es binario sin procesar — un digest HMAC con clave o una firma digital asimétrica. Está codificado en Base64url como los demás segmentos, pero sus bytes no son JSON y no tienen estructura legible por humanos.

En la práctica, decodificas JWTs en JavaScript por tres razones comunes. Primero, depuración: tienes un token de un flujo OAuth o un entorno de prueba y quieres confirmar que los claims coinciden con lo que debería haber emitido el servidor de autenticación. Segundo, leer claims de usuario para mostrarlos en el lado del cliente — mostrar el nombre del usuario autenticado, la URL del avatar o la insignia de rol a partir del payload del token sin una llamada extra a la API. Tercero, comprobar la expiración antes de intentar un refresco: si exp está dentro de los próximos 60 segundos, activa un refresco silencioso antes de la siguiente llamada a la API en lugar de esperar una respuesta 401.

Decodificar no comprueba si el token es válido o ha sido manipulado. Esa es una operación separada llamada verificación, que requiere el secreto HMAC o la clave pública RSA/ECDSA. Cualquiera puede decodificar un JWT. Solo el poseedor de la clave correcta puede verificarlo. Esta distinción confunde a muchos desarrolladores, especialmente al construir flujos de autenticación en el lado del cliente donde los claims decodificados se muestran pero nunca deben confiarse para decisiones de autorización sin una verificación verificada en el backend.

Before · json
After · json
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcxMTYxMDAwMH0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
// Encabezado
{ "alg": "HS256" }

// Payload
{
  "sub": "usr_921f",
  "role": "admin",
  "iat": 1711610000
}

atob() + TextDecoder — Decodificación de JWT nativa del navegador

El pipeline nativo del navegador para decodificar un JWT tiene cuatro pasos. Primero, divide la cadena del token por "." para obtener los tres segmentos. Segundo, normaliza el segmento base64url reemplazando - por + y _ por /, luego rellenando con caracteres = hasta que la longitud sea un múltiplo de 4. Tercero, llama a atob() para decodificar el Base64 en una cadena binaria. Cuarto, convierte la cadena binaria a UTF-8 adecuado usando TextDecoder. Ese último paso importa porque atob() devuelve Latin-1. Los caracteres multibyte — emoji, texto CJK, caracteres acentuados más allá del rango Latin-1 — salen corruptos sin el paso del decodificador de texto JavaScript.

JavaScript — decodificación mínima de JWT
const token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcxMTYxMDAwMH0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";

function decodeJwtPayload(jwt) {
  const base64Url = jwt.split(".")[1];
  const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
  const padded = base64.padEnd(base64.length + (4 - (base64.length % 4)) % 4, "=");
  const binary = atob(padded);
  const bytes = Uint8Array.from(binary, ch => ch.charCodeAt(0));
  const json = new TextDecoder("utf-8").decode(bytes);
  return JSON.parse(json);
}

console.log(decodeJwtPayload(token));
// { sub: "usr_921f", role: "admin", iat: 1711610000 }

El paso de relleno es fácil de pasar por alto. Los JWTs eliminan los caracteres = finales de sus segmentos Base64url porque la especificación JWT (RFC 7515) define base64url sin relleno. Pero atob() en algunos motores de navegador lanza un InvalidCharacterError si la longitud de entrada no es divisible por 4. Rellenar de forma defensiva con padEnd() evita ese caso extremo en todos los entornos. Aquí hay una versión reutilizable que decodifica tanto el encabezado como el payload en objetos separados:

JavaScript — decodificar encabezado y payload
function decodeBase64Url(segment) {
  const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
  const padded = base64.padEnd(base64.length + (4 - (base64.length % 4)) % 4, "=");
  const binary = atob(padded);
  const bytes = Uint8Array.from(binary, ch => ch.charCodeAt(0));
  return new TextDecoder("utf-8").decode(bytes);
}

function decodeJwt(token) {
  const [headerB64, payloadB64] = token.split(".");
  return {
    header: JSON.parse(decodeBase64Url(headerB64)),
    payload: JSON.parse(decodeBase64Url(payloadB64)),
  };
}

const { header, payload } = decodeJwt(token);
console.log("Algorithm:", header.alg);   // "HS256"
console.log("Subject:", payload.sub);     // "usr_921f"
console.log("Role:", payload.role);       // "admin"

Una vez que tienes estas dos funciones, vale la pena colocarlas en un módulo de utilidades compartido en lugar de copiar y pegar la lógica en distintos archivos. Un archivo src/lib/jwt.ts o utils/jwt-decode.ts con una forma de retorno tipada hace que la intención sea explícita en todo el código base. En TypeScript, puedes tipar el retorno como { header: JwtHeader; payload: JwtPayload } donde JwtHeader incluye alg, typ, y el opcional kid, y JwtPayload extiende los claims registrados del RFC 7519 con una firma de índice para claims personalizados. Centralizar la lógica de decodificación significa que cuando más tarde quieras añadir manejo de errores (capturar segmentos mal formados) o telemetría (registrar fallos de decodificación), solo tienes un lugar donde actualizar.

Nota:El paso de TextDecoder es lo que hace que este pipeline sea seguro para claims no ASCII. Sin él, atob() devuelve una cadena Latin-1 donde las secuencias UTF-8 multibyte se dividen entre caracteres. Verás basura en lugar de emoji o texto CJK. Siempre pasa por new TextDecoder("utf-8") después de atob().

Decodificación de claims JWT UTF-8 con caracteres multibyte

Los payloads JWT son JSON UTF-8 codificado como base64url. La mayoría de los payloads contienen campos de solo ASCII como IDs de usuario y marcas de tiempo, por lo que los desarrolladores nunca notan que atob() devuelve Latin-1 en lugar de UTF-8. El problema aparece en el momento en que un claim contiene emoji, caracteres japoneses, cirílico, o cualquier punto de código por encima de U+00FF. El patrón de decodificación UTF-8 en JavaScript requiere convertir primero la cadena binaria a un array de bytes y luego procesarla con TextDecoder.

JavaScript — ciclo completo UTF-8 con emoji en payload JWT
// Simulando un payload JWT con emoji y caracteres CJK
const payloadObj = {
  sub: "usr_e821",
  display_name: "田中太郎",
  team: "Platform 🚀",
  region: "ap-northeast-1"
};

// Codificar: objeto → JSON → bytes UTF-8 → base64url
const jsonStr = JSON.stringify(payloadObj);
const utf8Bytes = new TextEncoder().encode(jsonStr);
const base64 = btoa(String.fromCharCode(...utf8Bytes))
  .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");

// Decodificar: base64url → base64 → cadena binaria → bytes → cadena UTF-8
const base64Std = base64.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(base64Std);
const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
const decoded = new TextDecoder("utf-8").decode(bytes);
const result = JSON.parse(decoded);

console.log(result.display_name); // "田中太郎" — correcto
console.log(result.team);         // "Platform 🚀" — correcto

Hay un patrón de fallback heredado que verás en bases de código más antiguas que usa decodeURIComponent combinado con un truco de codificación porcentual. Este enfoque de decodeURIComponent en JavaScript funciona porque re-codifica cada byte como un par hexadecimal porcentual, y luego decodeURIComponent reensambla las secuencias UTF-8 multibyte:

JavaScript — fallback decodeURIComponent para UTF-8
function decodeBase64UrlLegacy(segment) {
  const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
  const binary = atob(base64);
  // Convierte cada carácter a %XX hexadecimal, luego decodeURIComponent reensambla UTF-8
  const utf8 = decodeURIComponent(
    binary.split("").map(c =>
      "%" + c.charCodeAt(0).toString(16).padStart(2, "0")
    ).join("")
  );
  return utf8;
}

// Funciona para claims no ASCII sin TextDecoder
const payload = decodeBase64UrlLegacy(token.split(".")[1]);
console.log(JSON.parse(payload));
Aviso:Puede que encuentres el patrón heredado decodeURIComponent(escape(atob(segment))) en fragmentos de utilidades JWT más antiguas. La función escape() está deprecada y no es estándar. Reemplázala con el enfoque de TextDecoder mostrado anteriormente. El patrón del decodificador unescape de JavaScript tiene el mismo problema: unescape() está deprecada. Ambas funciones pueden ser eliminadas en futuros motores de JavaScript.

Pipeline de decodificación JWT — Referencia de pasos

Cada paso del pipeline de decodificación JWT nativo del navegador, con la API de JavaScript utilizada y lo que produce:

Parámetro / Paso
Tipo
Descripción
token.split(".")
string[]
Divide el JWT en los segmentos [encabezado, payload, firma]
base64url → base64
reemplazo de cadena
Reemplaza - por +, _ por /, rellena con = hasta un múltiplo de 4
atob(base64)
string
Decodifica una cadena Base64 estándar en una cadena binaria (Latin-1)
TextDecoder("utf-8")
TextDecoder
Convierte un Uint8Array de bytes sin procesar en una cadena UTF-8 correcta
JSON.parse()
object
Analiza la cadena JSON resultante en un objeto JavaScript

El equivalente en Node.js colapsa los pasos 2 al 4 en una sola llamada: Buffer.from(segment, "base64url").toString("utf-8"). La opción de codificación "base64url" gestiona internamente la conversión del alfabeto y el relleno.

Buffer.from() — El decodificador de cadenas Node.js para JWTs

Node.js tiene un camino mucho más simple. La clase Buffer acepta una codificación "base64url" directamente, por lo que te saltas el reemplazo manual de caracteres y el relleno. Esta es la ruta del decodificador de cadenas JavaScript para código del lado del servidor. Una sola línea convierte un segmento JWT en una cadena UTF-8, y maneja correctamente los caracteres multibyte sin pasos adicionales.

Node.js 18+ — decodificación de JWT con Buffer
const token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsIm9yZyI6ImFjbWUtY29ycCIsInJvbGUiOiJiaWxsaW5nIiwiaWF0IjoxNzExNjEwMDAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";

function decodeJwt(jwt) {
  const segments = jwt.split(".");
  return {
    header: JSON.parse(Buffer.from(segments[0], "base64url").toString("utf-8")),
    payload: JSON.parse(Buffer.from(segments[1], "base64url").toString("utf-8")),
  };
}

const { header, payload } = decodeJwt(token);
console.log(header);
// { alg: "HS256" }
console.log(payload);
// { sub: "usr_921f", org: "acme-corp", role: "billing", iat: 1711610000 }

Este es el enfoque que uso en cada proyecto Node.js. Es más corto, más rápido, y ya maneja UTF-8 correctamente. No se necesita TextDecoder, ni reemplazo de caracteres, ni cálculos de relleno. La clase Buffer es un decodificador de cadenas JavaScript que maneja el alfabeto base64url de forma nativa, lo que elimina toda una clase de errores relacionados con la sustitución de caracteres. Si tu código necesita ejecutarse tanto en el navegador como en Node.js, consulta las preguntas frecuentes al final para ver una función wrapper isomórfica que detecta el entorno en tiempo de ejecución.

Aquí hay un ejemplo más completo que muestra cómo extraer claims JWT comunes y convertir marcas de tiempo a fechas legibles, que es el patrón que usarás con más frecuencia en middleware y manejadores de rutas API:

Node.js — extracción práctica de claims JWT
function inspectToken(token) {
  const segments = token.split(".");
  if (segments.length !== 3) {
    throw new Error("Not a valid JWT — expected 3 dot-separated segments");
  }

  const header = JSON.parse(Buffer.from(segments[0], "base64url").toString("utf-8"));
  const payload = JSON.parse(Buffer.from(segments[1], "base64url").toString("utf-8"));

  const inspection = {
    algorithm: header.alg,
    tokenType: header.typ || "JWT",
    subject: payload.sub,
    issuer: payload.iss || "(not set)",
    audience: payload.aud || "(not set)",
    issuedAt: payload.iat ? new Date(payload.iat * 1000).toISOString() : "(not set)",
    expiresAt: payload.exp ? new Date(payload.exp * 1000).toISOString() : "(never)",
    isExpired: payload.exp ? payload.exp < Math.floor(Date.now() / 1000) : false,
    customClaims: Object.keys(payload).filter(
      k => !["sub", "iss", "aud", "iat", "exp", "nbf", "jti"].includes(k)
    ),
  };

  return inspection;
}

console.log(inspectToken(process.env.ACCESS_TOKEN));
// {
//   algorithm: "RS256",
//   tokenType: "JWT",
//   subject: "usr_921f",
//   issuer: "https://auth.internal",
//   audience: "billing-api",
//   issuedAt: "2026-03-10T14:00:00.000Z",
//   expiresAt: "2026-03-10T15:00:00.000Z",
//   isExpired: true,
//   customClaims: ["role", "scope", "org"]
// }

En servicios Node.js de producción, el patrón de decodificación con Buffer.from() aparece en tres lugares recurrentes. El primero es el middleware de registro de solicitudes: decodificas el encabezado Authorization entrante para adjuntar userId y org a cada entrada de log estructurada sin un viaje de red adicional al servidor de autenticación. El segundo es la depuración: imprimes los claims del token decodificado en la consola durante el desarrollo para confirmar que se emitieron los scopes correctos antes de escribir aserciones de prueba. El tercero es la renovación proactiva de tokens en pasarelas API. En lugar de reenviar un token aguas arriba y dejar que el servicio downstream devuelva un 401 cuando el token expira a mitad de la solicitud, la pasarela decodifica el token en el borde, lee el claim exp y activa una renovación si la expiración está dentro de los próximos 30 segundos. Esto elimina una clase de fallos de autenticación transitorios que son difíciles de reproducir y frustrantes de depurar.

Nota:La codificación "base64url" se añadió en Node.js 15.7.0. Si estás en Node.js 14 o anterior, usa Buffer.from(segment.replace(/-/g, "+").replace(/_/g, "/"), "base64") que funciona igual pero requiere el intercambio manual de caracteres.

Decodificar JWT desde un archivo y una respuesta API

Dos escenarios surgen constantemente. El primero es leer un JWT desde un archivo local: un token guardado durante el desarrollo, un fixture de prueba, o un archivo volcado durante un incidente para análisis post-mortem. El segundo es extraer un JWT de una respuesta HTTP, normalmente el campo access_token en el cuerpo de una respuesta de token OAuth o un encabezado Authorization. Ambos necesitan manejo de errores porque los tokens mal formados, los archivos truncados y los errores de red son realidades cotidianas. Un token que era válido la semana pasada puede tener espacios en blanco o saltos de línea por copiar y pegar. Un cuerpo de respuesta puede ser HTML en lugar de JSON si el servidor de autenticación devolvió una página de error.

Leer JWT desde un archivo (Node.js)

Node.js — decodificar JWT desde archivo con manejo de errores
import { readFileSync } from "node:fs";

function decodeJwtFromFile(filePath) {
  const raw = readFileSync(filePath, "utf-8").trim();
  const segments = raw.split(".");

  if (segments.length !== 3) {
    throw new Error(`Invalid JWT: expected 3 segments, got ${segments.length}`);
  }

  try {
    return {
      header: JSON.parse(Buffer.from(segments[0], "base64url").toString("utf-8")),
      payload: JSON.parse(Buffer.from(segments[1], "base64url").toString("utf-8")),
    };
  } catch (err) {
    throw new Error(`Failed to decode JWT from ${filePath}: ${err.message}`);
  }
}

try {
  const { header, payload } = decodeJwtFromFile("./test-fixtures/access-token.txt");
  console.log("Algorithm:", header.alg);
  console.log("Expires:", new Date(payload.exp * 1000).toISOString());
} catch (err) {
  console.error(err.message);
}

Extraer JWT de una respuesta API (fetch)

JavaScript — decodificar JWT desde respuesta API
async function fetchAndDecodeToken(loginUrl, credentials) {
  const response = await fetch(loginUrl, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(credentials),
  });

  if (!response.ok) {
    throw new Error(`Login failed: ${response.status} ${response.statusText}`);
  }

  const { access_token } = await response.json();
  if (!access_token || access_token.split(".").length !== 3) {
    throw new Error("Response does not contain a valid JWT");
  }

  const payload = access_token.split(".")[1];
  const json = Buffer.from(payload, "base64url").toString("utf-8");
  return JSON.parse(json);
}

// Uso
try {
  const claims = await fetchAndDecodeToken(
    "https://auth.internal/oauth/token",
    { username: "deploy-bot", password: process.env.DEPLOY_TOKEN }
  );
  console.log("Token subject:", claims.sub);
  console.log("Token scopes:", claims.scope);
  console.log("Expires at:", new Date(claims.exp * 1000).toISOString());
} catch (err) {
  console.error("Token decode error:", err.message);
}

Decodificación de JWT desde la línea de comandos

A veces solo quieres echar un vistazo a un token desde la terminal sin escribir un script. Node.js está disponible en la mayoría de las máquinas de desarrolladores, por lo que un comando de una sola línea funciona bien. jq se encarga del formateo con sangría.

bash — decodificar payload JWT desde la terminal
# Decodificar payload JWT con Node.js en una línea
echo "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsInJvbGUiOiJhZG1pbiJ9.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" \
  | cut -d. -f2 \
  | node -e "process.stdin.on('data', d => console.log(JSON.parse(Buffer.from(d.toString().trim(), 'base64url').toString('utf-8'))))"

# Redirigir a jq para salida con formato
echo "$JWT_TOKEN" | cut -d. -f2 \
  | node -e "process.stdin.on('data', d => process.stdout.write(Buffer.from(d.toString().trim(), 'base64url').toString('utf-8')))" \
  | jq .

# Decodificar tanto el encabezado como el payload
echo "$JWT_TOKEN" | node -e "
  process.stdin.on('data', d => {
    const parts = d.toString().trim().split('.');
    console.log('Header:', JSON.parse(Buffer.from(parts[0], 'base64url').toString()));
    console.log('Payload:', JSON.parse(Buffer.from(parts[1], 'base64url').toString()));
  });
"

Si prefieres bash puro sin Node.js, redirige el segmento a través de base64 -d después de corregir los caracteres base64url con tr:

bash — decodificación JWT en bash puro sin Node.js
# Bash puro: decodificar payload JWT sin Node.js
echo "$JWT_TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .

# Variante para macOS (base64 -D en lugar de -d)
echo "$JWT_TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -D 2>/dev/null | jq .

Para una inspección visual rápida sin ninguna terminal, pega tu token en el Decodificador JWT de ToolDeck para obtener un desglose en paralelo de los tres segmentos con etiquetas de claims con código de colores y el estado de expiración.

jose — Verificación y decodificación en una sola librería

Para el middleware de autenticación en producción, necesitas verificación de firma, no solo decodificación. La librería jose es la mejor opción. Funciona tanto en Node.js como en navegadores (a través de la Web Crypto API), soporta HS256, RS256, ES256, EdDSA y JWE (tokens cifrados), y no tiene dependencias nativas. Instala con npm install jose.

JavaScript — jose: verificar token HS256
import * as jose from "jose";

const secret = new TextEncoder().encode("k8s-webhook-signing-secret-2026");
const token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsInNjb3BlIjoiYmlsbGluZzpyZWFkIiwiaWF0IjoxNzExNjEwMDAwLCJleHAiOjE3MTE2MTM2MDB9.abc123";

try {
  const { payload, protectedHeader } = await jose.jwtVerify(token, secret);
  console.log("Algorithm:", protectedHeader.alg); // "HS256"
  console.log("Subject:", payload.sub);            // "usr_921f"
  console.log("Scope:", payload.scope);            // "billing:read"
} catch (err) {
  if (err.code === "ERR_JWT_EXPIRED") {
    console.error("Token expired at:", err.payload.exp);
  } else {
    console.error("Verification failed:", err.message);
  }
}
JavaScript — jose: verificar RS256 con endpoint JWKS
import * as jose from "jose";

// Obtener el conjunto de claves públicas del proveedor de identidad
const jwks = jose.createRemoteJWKSet(
  new URL("https://auth.internal/.well-known/jwks.json")
);

const token = req.headers.authorization?.split(" ")[1];
if (!token) {
  return res.status(401).json({ error: "Missing token" });
}

try {
  const { payload } = await jose.jwtVerify(token, jwks, {
    issuer: "https://auth.internal",
    audience: "billing-api",
  });
  // payload.sub, payload.scope, etc. ahora están verificados
  req.userId = payload.sub;
} catch (err) {
  return res.status(401).json({ error: "Invalid token" });
}

Al decidir entre jose y el paquete más antiguo jsonwebtoken, la diferencia clave es el alcance del runtime. jsonwebtoken es exclusivo de Node.js — depende del módulo integrado crypto y no se puede empaquetar para el navegador. jose es completamente isomórfico: usa la Web Crypto API, disponible en todos los navegadores modernos, Node.js 16+, Deno, Bun y Cloudflare Workers. Si tu lógica de autenticación vive en un archivo middleware de Next.js (que se ejecuta en el Edge Runtime), en un Cloudflare Worker, o en una utilidad compartida que es importada tanto por código de servidor como de cliente, jose es la elección correcta porque no tiene dependencias nativas y se instala sin un paso de compilación. jsonwebtoken sigue siendo razonable para aplicaciones de servidor Node.js puras donde necesitas su ecosistema más amplio de helpers de firma y no planeas ejecutar el código en un entorno edge. En un proyecto nuevo en 2026, usa jose por defecto a menos que tengas una razón específica para preferir la API más antigua.

Si solo necesitas decodificación sin verificación, jose proporciona jose.decodeJwt(token) que devuelve el payload y jose.decodeProtectedHeader(token) para el encabezado. Estas son funciones de conveniencia que hacen la decodificación Base64url internamente. Pero la razón principal para usar jose es que raramente deberías decodificar sin también verificar. Si estás en el lado del cliente y solo necesitas mostrar al usuario su propio nombre de visualización o URL de avatar a partir de los claims del token, la decodificación sin verificación está bien. En el servidor, verifica siempre. He visto sistemas de producción que decodificaban claims JWT para decisiones de control de acceso sin comprobar la firma, y esa es una puerta abierta para cualquier atacante que entienda el formato JWT.

JavaScript — jose.decodeJwt para escenarios de solo decodificación
import * as jose from "jose";

// Solo decodificación: no se necesita secreto, sin verificación
const payload = jose.decodeJwt(token);
console.log(payload.sub);   // "usr_921f"
console.log(payload.scope); // "billing:read"

const header = jose.decodeProtectedHeader(token);
console.log(header.alg);    // "HS256"
console.log(header.typ);    // "JWT"

// Comprobar expiración sin verificación (visualización en cliente)
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
  console.log("Token has expired — redirect to login");
}

Salida en terminal con resaltado de sintaxis

Al depurar tokens JWT en una herramienta CLI de Node.js o durante un incidente, la salida con código de colores hace una diferencia real. La librería chalk combinada con JSON.stringify hace el trabajo. Instala con npm install chalk.

Node.js — salida de decodificación JWT con colores
import chalk from "chalk";

function printJwt(token) {
  const segments = token.split(".");
  if (segments.length !== 3) {
    console.error(chalk.red("Invalid JWT: expected 3 segments"));
    return;
  }

  const header = JSON.parse(Buffer.from(segments[0], "base64url").toString("utf-8"));
  const payload = JSON.parse(Buffer.from(segments[1], "base64url").toString("utf-8"));

  console.log(chalk.bold.cyan("\n=== JWT Header ==="));
  console.log(chalk.gray(JSON.stringify(header, null, 2)));

  console.log(chalk.bold.green("\n=== JWT Payload ==="));
  console.log(chalk.gray(JSON.stringify(payload, null, 2)));

  // Resaltar estado de expiración
  if (payload.exp) {
    const expiresAt = new Date(payload.exp * 1000);
    const isExpired = expiresAt < new Date();
    console.log(
      chalk.bold("\nExpires:"),
      isExpired
        ? chalk.red(`EXPIRED at ${expiresAt.toISOString()}`)
        : chalk.green(`Valid until ${expiresAt.toISOString()}`)
    );
  }

  console.log(chalk.dim("\nSignature: " + segments[2].substring(0, 20) + "..."));
}

printJwt(process.argv[2]);
// Ejecutar: node jwt-debug.mjs "eyJhbGci..."
Nota:La salida con colores es solo para la terminal. No uses chalk cuando escribas claims JWT en archivos de log, respuestas API o campos de base de datos. Los códigos de escape ANSI aparecerán como basura en contextos que no son terminales.

Procesamiento de JWTs desde archivos de log grandes

La infraestructura API moderna emite logs de acceso estructurados en formato NDJSON — un objeto JSON por línea, con cada línea conteniendo la ruta de la solicitud, el estado de la respuesta, la latencia y el encabezado Authorization decodificado o sin procesar. En un servicio muy concurrido estos archivos crecen rápidamente: una pasarela que maneja 10.000 solicitudes por minuto produce más de 14 millones de entradas de log al día. Los casos de uso de seguridad y cumplimiento normativo requieren regularmente escanear estos archivos a posteriori — identificar cada solicitud realizada por una cuenta de servicio comprometida (análisis post-incidente), confirmar que los tokens de un usuario específico expiraron antes de una ventana de acceso a datos (auditoría de cumplimiento), o extraer el conjunto completo de sujetos que accedieron a un endpoint sensible durante una ventana de mantenimiento. Dado que un solo archivo de log puede superar varios gigabytes, cargarlo en memoria con readFileSync no es viable. Los streams readline de Node.js procesan el archivo línea por línea con uso de memoria constante, lo que hace práctico escanear logs arbitrariamente grandes en un portátil de desarrollador estándar.

No te encontrarás con el problema de "archivo demasiado grande para la memoria" con JWTs individuales, ya que un solo token raramente supera unos pocos kilobytes. El escenario que sí surge es escanear un log de acceso grande o una pista de auditoría en busca de tokens JWT, decodificar cada uno y extraer claims específicos. Los streams de Node.js manejan esto sin cargar todo el archivo.

Node.js — stream de log NDJSON y decodificación de JWTs incrustados
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";

async function scanLogsForExpiredTokens(logPath) {
  const fileStream = createReadStream(logPath, { encoding: "utf-8" });
  const rl = createInterface({ input: fileStream, crlfDelay: Infinity });

  let lineCount = 0;
  let expiredCount = 0;
  const nowSeconds = Math.floor(Date.now() / 1000);

  for await (const line of rl) {
    lineCount++;
    try {
      const entry = JSON.parse(line);
      if (!entry.authorization_token) continue;

      const segments = entry.authorization_token.split(".");
      if (segments.length !== 3) continue;

      const payload = JSON.parse(
        Buffer.from(segments[1], "base64url").toString("utf-8")
      );

      if (payload.exp && payload.exp < nowSeconds) {
        expiredCount++;
        const expDate = new Date(payload.exp * 1000).toISOString();
        console.log("Line " + lineCount + ": expired token for " + payload.sub + ", exp=" + expDate);
      }
    } catch {
      // Omitir líneas mal formadas
    }
  }

  console.log(`\nScanned ${lineCount} lines, found ${expiredCount} expired tokens`);
}

scanLogsForExpiredTokens("./logs/api-access-2026-03.ndjson");
Node.js — extraer sujetos JWT únicos desde stream de log
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";

async function extractUniqueSubjects(logPath) {
  const rl = createInterface({
    input: createReadStream(logPath, { encoding: "utf-8" }),
    crlfDelay: Infinity,
  });

  const subjects = new Set();
  const jwtRegex = /eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;

  for await (const line of rl) {
    const matches = line.match(jwtRegex);
    if (!matches) continue;

    for (const token of matches) {
      try {
        const payload = JSON.parse(
          Buffer.from(token.split(".")[1], "base64url").toString("utf-8")
        );
        if (payload.sub) subjects.add(payload.sub);
      } catch {
        // No es un JWT válido
      }
    }
  }

  console.log(`Found ${subjects.size} unique subjects:`);
  for (const sub of subjects) console.log(`  ${sub}`);
}

extractUniqueSubjects("./logs/gateway-2026-03.log");
Nota:Usa streams cuando el archivo de log supere los 50 MB. Cargar un archivo NDJSON de 500 MB con readFileSync ocupará toda la memoria y provocará pausas del GC. El enfoque con readline procesa una línea a la vez con uso de memoria constante.

Errores comunes

Usar atob() sin TextDecoder para claims no ASCII

Problema: atob() devuelve una cadena Latin-1. Los caracteres UTF-8 multibyte (emoji, CJK, caracteres acentuados) se dividen entre caracteres y salen corruptos.

Solución: Convierte la salida de atob() a un Uint8Array y luego pásalo por new TextDecoder('utf-8').

Before · JavaScript
After · JavaScript
// Falla con claims de payload no ASCII
const payload = JSON.parse(atob(token.split(".")[1]));
// display_name aparece como "ç°ä¸­å¤ªé\x90\x8E" en lugar de "田中太郎"
const binary = atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"));
const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
const payload = JSON.parse(new TextDecoder("utf-8").decode(bytes));
// display_name muestra correctamente "田中太郎"
Olvidar el reemplazo de caracteres de base64url a base64

Problema: atob() lanza "InvalidCharacterError" porque base64url usa - y _ en lugar de + y /.

Solución: Reemplaza - por + y _ por / antes de llamar a atob(). Node.js Buffer.from() con 'base64url' lo maneja automáticamente.

Before · JavaScript
After · JavaScript
// Lanza: InvalidCharacterError: String contains an invalid character
const payload = atob(token.split(".")[1]);
const segment = token.split(".")[1];
const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
const payload = atob(base64); // ahora funciona
Confiar en claims JWT decodificados sin verificación de firma

Problema: Cualquiera puede crear un JWT con cualquier payload. Decodificar solo lee los datos — no prueba que el token fue emitido por tu servidor de autenticación.

Solución: En el servidor, verifica siempre la firma usando jose.jwtVerify() o jsonwebtoken.verify(). La decodificación sin verificación es aceptable solo para mostrar claims de usuario en el cliente.

Before · JavaScript
After · JavaScript
// PELIGROSO: decodificado pero no verificado
const claims = JSON.parse(atob(token.split(".")[1]));
if (claims.role === "admin") {
  grantAdminAccess(); // un atacante puede falsificar esto
}
import * as jose from "jose";
const { payload } = await jose.jwtVerify(token, secretKey);
if (payload.role === "admin") {
  grantAdminAccess(); // seguro — la firma está verificada
}
Comparar exp con Date.now() sin dividir entre 1000

Problema: El exp del JWT está en segundos desde el epoch, pero Date.now() devuelve milisegundos. La comparación siempre dirá que el token es válido porque la marca de tiempo en milisegundos es 1000 veces mayor.

Solución: Divide Date.now() entre 1000 y aplica floor al resultado antes de comparar con exp.

Before · JavaScript
After · JavaScript
// Error: Date.now() está en milisegundos, exp está en segundos
if (payload.exp > Date.now()) {
  console.log("Token is valid"); // siempre true — ¡incorrecto!
}
const nowSeconds = Math.floor(Date.now() / 1000);
if (payload.exp > nowSeconds) {
  console.log("Token is valid"); // comparación correcta
}

Métodos de decodificación JWT — Comparación rápida

Método
Entorno
UTF-8 seguro
Verificación de firma
Tipos personalizados
Requiere instalación
atob() + TextDecoder
Navegador
N/A (solo lectura)
No
Buffer.from()
Node.js
N/A (solo lectura)
No
decodeURIComponent()
Navegador (legado)
N/A (solo lectura)
No
jose
Ambos
✓ (JWS/JWE)
npm install
jsonwebtoken
Node.js
npm install
jwt-decode
Ambos
N/A
npm install

Usa atob() + TextDecoder para decodificación en el navegador cuando solo necesitas mostrar claims al usuario. Usa Buffer.from() en scripts de Node.js y herramientas CLI. Recurre a jose en el momento en que necesites verificar una firma, que es cualquier middleware de autenticación del lado del servidor. El paquete jwt-decode es una alternativa ligera si quieres una API de función única para decodificación sin verificación en el navegador. Para una inspección visual rápida sin escribir código, pega tu token en la herramienta JWT Decoder.

Preguntas frecuentes

¿Cómo decodifico un token JWT en JavaScript sin una librería?

Divide el token por ".", toma el segundo segmento (el payload), normaliza la codificación base64url reemplazando - por + y _ por /, rellena con caracteres =, luego llama a atob() seguido de TextDecoder para obtener la cadena JSON en UTF-8. Pasa el resultado por JSON.parse() y obtendrás el objeto de claims. No se requiere ningún paquete npm. Este enfoque funciona en todos los navegadores modernos y en Node.js 18+. Si también necesitas leer el encabezado, aplica los mismos pasos de decodificación al primer segmento. Ten en cuenta que esto te da los datos sin procesar sin verificación de firma — trata el resultado como solo para visualización a menos que verifiques la firma en el servidor.

JavaScript
const token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c3JfOTIxZiIsInJvbGUiOiJhZG1pbiJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
const payload = token.split(".")[1];
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
const json = atob(base64);
const claims = JSON.parse(json);
console.log(claims);
// { sub: "usr_921f", role: "admin" }

¿Cuál es la diferencia entre atob() y Buffer.from() para decodificar JWT?

atob() es una API del navegador que decodifica Base64 estándar en una cadena binaria Latin-1. No entiende la codificación base64url directamente, por lo que debes reemplazar primero los caracteres - y _. Buffer.from(segment, "base64url") es una API de Node.js que maneja el alfabeto base64url de forma nativa y devuelve un Buffer sobre el que puedes llamar .toString("utf-8"). Usa atob() en el navegador y Buffer.from() en Node.js. Una tercera opción — más lenta pero históricamente común — es el truco de codificación porcentual con decodeURIComponent, pero ese patrón depende de la función deprecada escape() en algunos fragmentos más antiguos y debe evitarse en código nuevo. Para código isomórfico que se ejecuta en ambos entornos, comprueba typeof Buffer !== "undefined" y ramifica en consecuencia.

JavaScript
// Navegador
const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));

// Node.js
const json2 = Buffer.from(payload, "base64url").toString("utf-8");

¿Por qué atob() falla con claims JWT que contienen caracteres no ASCII?

atob() devuelve una cadena Latin-1 donde cada carácter corresponde a un solo byte. Las secuencias UTF-8 multibyte (emoji, caracteres CJK, letras acentuadas más allá de Latin-1) se dividen en múltiples caracteres, produciendo una salida corrupta. La solución es convertir primero la cadena binaria a un Uint8Array y luego pasarlo a new TextDecoder("utf-8").decode(). La API TextDecoder reensambla correctamente las secuencias multibyte. Este problema es fácil de pasar por alto en desarrollo porque la mayoría de los payloads JWT contienen solo identificadores de usuario ASCII, marcas de tiempo y nombres de rol — el error solo aparece cuando un claim contiene un nombre de visualización no ASCII o una cadena localizada. Usa siempre la ruta TextDecoder en código nuevo aunque tus payloads actuales sean solo ASCII, ya que los claims pueden cambiar a medida que la aplicación evoluciona.

JavaScript
// Roto: atob devuelve Latin-1, los caracteres multibyte se corrompen
const broken = atob(base64); // "ð\x9F\x8E\x89" en lugar del emoji

// Correcto: convertir a array de bytes, luego TextDecoder
const bytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
const fixed = new TextDecoder("utf-8").decode(bytes);

¿Puedo verificar la firma de un JWT en JavaScript?

Decodificar y verificar son operaciones distintas. Decodificar solo lee el payload, que no está cifrado. La verificación comprueba la firma contra un secreto (HMAC) o una clave pública (RSA/ECDSA). La librería jose soporta ambas en el navegador a través de la Web Crypto API y en Node.js. El paquete jsonwebtoken funciona solo en Node.js. Nunca confíes en los claims decodificados sin verificar la firma en el servidor. En el lado del cliente es aceptable decodificar un JWT para leer el nombre de visualización del usuario o el tiempo de expiración, pero cualquier decisión de control de acceso — verificar si un usuario tiene un rol o permiso particular — debe ocurrir en código del lado del servidor después de la verificación. Un atacante que comprende el formato JWT puede crear un token con claims arbitrarios y tu verificación del lado del cliente pasará.

JavaScript
import * as jose from "jose";

const secret = new TextEncoder().encode("your-256-bit-secret");
const { payload } = await jose.jwtVerify(token, secret);
console.log(payload.sub); // claims verificados

¿Cómo compruebo si un JWT ha expirado en JavaScript?

Decodifica el payload y lee el claim exp, que es una marca de tiempo Unix en segundos. Compáralo con la hora actual usando Math.floor(Date.now() / 1000). Si la hora actual es mayor que exp, el token ha expirado. Recuerda: el valor de exp está en segundos desde el epoch, no en milisegundos, por lo que dividir Date.now() entre 1000 es necesario. En la práctica, incluye un pequeño margen para la diferencia de reloj — comprobar si el token expira en los próximos 30 segundos en lugar de estrictamente en el pasado previene casos extremos donde el token aún es técnicamente válido cuando lo decodificas pero expira antes de que la siguiente llamada a la API lo procese. Maneja también el caso en que exp está completamente ausente, lo que significa que el token nunca expira.

JavaScript
function isTokenExpired(token) {
  const payload = JSON.parse(
    atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))
  );
  const nowSeconds = Math.floor(Date.now() / 1000);
  return payload.exp < nowSeconds;
}

console.log(isTokenExpired(myToken)); // true o false

¿Cómo escribo código isomórfico de decodificación JWT que funcione tanto en Node.js como en el navegador?

Comprueba la existencia de globalThis.Buffer. Si existe, estás en Node.js y puedes usar Buffer.from(segment, "base64url").toString("utf-8"). Si no existe, estás en un navegador y debes usar atob() con el enfoque TextDecoder. Encapsula esta comprobación en una única función decodeBase64Url y úsala en todos lados. Esto importa más para paquetes de utilidades, componentes de sistemas de diseño y cualquier código compartido que vive en un paquete de monorepo importado tanto por un componente de servidor de Next.js como por un componente React del navegador. Mantener la detección del entorno en un solo lugar significa que solo necesitas actualizarlo en un sitio si el runtime cambia — por ejemplo, cuando Deno añade soporte completo de Buffer o un nuevo runtime de edge requiere una ruta de código diferente.

JavaScript
function decodeBase64Url(segment) {
  if (typeof Buffer !== "undefined") {
    return Buffer.from(segment, "base64url").toString("utf-8");
  }
  const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
  const bytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
  return new TextDecoder("utf-8").decode(bytes);
}

Herramientas relacionadas

También disponible en:Python
MW
Marcus WebbJavaScript Performance Engineer

Marcus specialises in JavaScript performance, build tooling, and the inner workings of the V8 engine. He has spent years profiling and optimising React applications, working on bundler configurations, and squeezing every millisecond out of critical rendering paths. He writes about Core Web Vitals, JavaScript memory management, and the tools developers reach for when performance really matters.

SL
Sophie LaurentRevisor técnico

Sophie is a full-stack developer focused on TypeScript across the entire stack — from React frontends to Express and Fastify backends. She has a particular interest in type-safe API design, runtime validation, and the patterns that make large JavaScript codebases stay manageable. She writes about TypeScript idioms, Node.js internals, and the ever-evolving JavaScript module ecosystem.