Qué es realmente un JWT
Un JSON Web Token es una cadena compacta y segura para URL que codifica un conjunto de claims junto con una firma sobre ellos. Está definido por el RFC 7519 y se basa en dos especificaciones complementarias: JSON Web Signature (JWS) para la firma y JSON Web Encryption (JWE) para la confidencialidad. Casi todos los JWT que encontrarás en la práctica son JWS.
El token consta de tres partes codificadas en Base64url y separadas por puntos: header, payload y signature. Es autocontenido, lo que significa que todo lo que un servidor necesita para tomar una decisión viaja con la solicitud. Validarlo no requiere consultas a la base de datos, ni un almacén de sesiones compartido, ni llamadas al emisor.
Vale la pena ser precisos sobre qué es lo que esto demuestra. Un JWT demuestra que quien lo emitió firmó exactamente ese payload. No demuestra que el token estuviera destinado a ti a menos que verifiques la audiencia. No demuestra que el usuario todavía tenga permiso para actuar a menos que verifiques los scopes y roles. Y no oculta nada a menos que utilices JWE. Cada propiedad de seguridad que te interese debe ser verificada explícitamente.
Las tres partes, decodificadas
El header es un pequeño objeto JSON que indica el algoritmo de firma y el tipo de token. El campo alg es el más crítico. typ es casi siempre JWT, y kid identifica qué clave se utilizó para que los verificadores puedan encontrar la correcta durante la rotación de claves.
{ "alg": "HS256", "typ": "JWT", "kid": "2026-09" }
El payload es un objeto JSON de claims. Los registered claims tienen nombres estándar definidos por la especificación: iss para el emisor (issuer), sub para el sujeto (subject), aud para la audiencia (audience), exp para la expiración (expiration), nbf para el “no antes de” (not before), iat para la fecha de emisión (issued at), y jti para un ID de token único. Todo lo demás es un custom claim que tú defines.
{
"iss": "https://auth.example.com",
"sub": "user_42",
"aud": "api.example.com",
"exp": 1760000000,
"iat": 1759999100,
"scope": "read:posts write:posts",
"role": "editor"
}
La signature se calcula sobre el Base64url del header y el payload. Si cambias un solo carácter en cualquiera de los dos, la firma recalculada no coincidirá. Esta es la propiedad que hace que un JWT sea seguro para entregarlo a un cliente no confiable, y es lo único que separa un claim de una falsificación.
Firmados, no cifrados
El malentendido más común sobre los JWT es que son secretos. No lo son. El payload está en Base64url, que es una codificación, no un cifrado. Cualquier persona que posea el token puede decodificar cada claim con una sola línea de código.
const [, payload] = token.split(".");
console.log(JSON.parse(atob(payload)));
// { sub: "user_42", role: "editor", scope: "read:posts" }
Esto tiene dos consecuencias. Primero, nunca pongas secretos, contraseñas o datos personales en un JWT que no te sentirías cómodo mostrando al cliente. Segundo, trata el token en sí mismo como una credencial: poseerlo es suficiente para actuar como el sujeto, razón por la cual el almacenamiento y el transporte son tan importantes más adelante en esta guía.
Si realmente necesitas ocultar el payload al cliente, utiliza JSON Web Encryption y una librería que lo soporte. Para la gran mayoría de los sistemas, un JWS firmado sobre TLS es la respuesta correcta, y añadir cifrado solo añade una pieza móvil más al sistema.
Algoritmos de firma: HS256 vs RS256
Dos familias de algoritmos dominan los despliegues reales.
HS256 es HMAC con SHA-256. El mismo secreto se utiliza para firmar y verificar. Es rápido, sencillo e ideal cuando un único servicio tanto emite como verifica sus propios tokens.
import { SignJWT, jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const token = await new SignJWT({ role: "editor" })
.setProtectedHeader({ alg: "HS256" })
.setSubject("user_42")
.setExpirationTime("15m")
.sign(secret);
await jwtVerify(token, secret, { algorithms: ["HS256"] });
RS256 es RSA con SHA-256, y ES256 es ECDSA sobre una curva prima. El emisor posee una clave privada y firma; todos los demás poseen la clave pública y verifican. Esa asimetría es la razón por la cual los sistemas grandes lo prefieren: un servidor de recursos puede verificar tokens sin poder emitirlos. ES256 ofrece la misma garantía con claves y firmas mucho más pequeñas.
import { importPKCS8, importSPKI, SignJWT, jwtVerify } from "jose";
const privateKey = await importPKCS8(process.env.JWT_PRIVATE_KEY!, "RS256");
const publicKey = await importSPKI(process.env.JWT_PUBLIC_KEY!, "RS256");
const token = await new SignJWT({ role: "editor" })
.setProtectedHeader({ alg: "RS256", kid: "2026-09" })
.setSubject("user_42")
.setExpirationTime("15m")
.sign(privateKey);
await jwtVerify(token, publicKey, {
algorithms: ["RS256"],
issuer: "https://auth.example.com",
audience: "api.example.com",
});
La regla que importa más que la elección: el verificador debe fijar el algoritmo esperado. Nunca permitas que el propio header del token seleccione cómo se debe verificar.
Claims: registrados y personalizados
Los registered claims están reservados por la especificación y son reconocidos por cualquier librería seria.
iss— quién emitió el token. Verifícalo para rechazar tokens provenientes de otro entorno.sub— a quién pertenece el token, generalmente el id del usuario.aud— para quién es el token. Un token emitido para tu API pública no debería ser aceptado por tu API de administración.exp— cuándo expira el token, como un Unix timestamp. Configúralo siempre.nbf— no antes de. Rara vez es necesario, pero es útil para despliegues graduales (staged rollouts).iat— cuándo fue emitido. Útil para comprobaciones de edad máxima y depuración.jti— un id único para este token, utilizado para la detección de replay y listas de denegación.
Los custom claims transportan datos de la aplicación: scope, role, tenant_id, email. Mantenlos pequeños y sin información sensible. Un token viaja en cada solicitud, por lo que un payload inflado es un impuesto permanente al ancho de banda y a la latencia.
Existe una fuerte tentación de incluir todo el perfil del usuario en el token para evitar una lectura en la base de datos. Resiste esa tentación. Los claims quedan obsoletos en el momento en que cambia un rol, y no puedes anular la emisión de un token que el cliente ya posee. Incluye solo lo que el verificador realmente necesite y consulta el resto en la base de datos.
Creación y verificación de tokens
En Node, jose es la opción moderna. Está basada en promesas, funciona en cualquier runtime incluyendo Cloudflare Workers y Deno, y expone una API pequeña y meticulosa. jsonwebtoken es la librería más antigua, basada en callbacks, y sigue siendo común en el código existente.
import { SignJWT } from "jose";
export async function issueAccessToken(userId: string, scope: string) {
return new SignJWT({ scope })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setSubject(userId)
.setIssuer("https://auth.example.com")
.setAudience("api.example.com")
.setIssuedAt()
.setExpirationTime("15m")
.setJti(crypto.randomUUID())
.sign(new TextEncoder().encode(process.env.JWT_SECRET));
}
La verificación es donde reside la seguridad. Un verificador debe comprobar la firma, fijar el algoritmo y validar exp, iss y aud. Una librería decodificará alegremente un token sin verificarlo, y ese payload decodificado es una entrada controlada por el atacante.
import { jwtVerify, type JWTPayload } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
export async function verifyAccessToken(token: string): Promise<JWTPayload> {
const { payload } = await jwtVerify(token, secret, {
algorithms: ["HS256"],
issuer: "https://auth.example.com",
audience: "api.example.com",
});
return payload;
}
Ten en cuenta que jwtVerify aplica exp y nbf automáticamente, pero solo comprueba iss y aud cuando se los pasas. Omitirlos es una vulnerabilidad real: un token emitido para un servicio de staging sería aceptado por producción, y un token emitido para una aplicación diferente también sería aceptado.
Tokens de acceso y de refresco
Un único token de larga duración es conveniente, pero peligroso. La solución estándar es utilizar un par de tokens con diferentes tiempos de vida y diferentes audiencias.
- El access token es de corta duración, típicamente de 5 a 15 minutos. Se envía en cada llamada a la API y es el único token que el servidor de recursos llega a ver.
- El refresh token es de larga duración, desde días hasta meses. Se envía únicamente al servidor de autorización para obtener un nuevo access token.
Esta división limita el daño. Si un access token se filtra, quedará inservible en cuestión de minutos. El refresh token, que es mucho más sensible, nunca viaja hacia los servidores de recursos y puede ser rotado y revocado, que es donde reside el control real.
Rotación de refresh tokens
La rotación significa que cada solicitud de refresco emite un nuevo refresh token e invalida el anterior. Si un atacante roba un refresh token y lo utiliza, el cliente legítimo presentará más tarde un token que ya ha sido usado; en ese momento, el servidor puede detectar la reutilización y revocar toda la familia de tokens.
import { randomUUID } from "node:crypto";
export async function rotateRefreshToken(presented: string) {
const stored = await db.refreshToken.findUnique({ where: { token: presented } });
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
if (stored) await revokeFamily(stored.familyId);
throw new Error("invalid_refresh_token");
}
await db.refreshToken.update({
where: { id: stored.id },
data: { revokedAt: new Date(), replacedBy: randomUUID() },
});
return issueTokenPair(stored.userId, stored.familyId);
}
Almacena los refresh tokens mediante hashing, exactamente igual que lo harías con las contraseñas. Una filtración de la base de datos no debería entregar credenciales activas a un atacante, ya que un refresh token es, en esencia, una contraseña que permite saltarse el formulario de inicio de sesión.
Dónde almacenar tokens en el navegador
No existe un lugar perfecto, solo compromisos entre cross-site scripting y cross-site request forgery.
- localStorage es legible por cualquier script en la página. Un solo bug de XSS y el atacante exfiltra todos los tokens. Este es el error que sigue apareciendo en los reportes de brechas de seguridad.
- En memoria, mediante una variable en un módulo, es seguro frente a XSS persistente pero se pierde al recargar la página, por lo que generalmente se combina con un refresh token en una cookie httpOnly.
- Una cookie httpOnly, Secure y SameSite es ilegible para JavaScript, lo que neutraliza el robo de tokens vía XSS. Esto reintroduce el CSRF, el cual se mitiga mediante
SameSite=LaxoStrictjunto con un token CSRF.
Para una aplicación de navegador, la opción pragmática por defecto es un access token de corta duración mantenido en memoria y un refresh token rotativo en una cookie httpOnly limitada al endpoint de refresco. Los clientes nativos y del lado del servidor no tienen esta restricción y pueden guardar los tokens en almacenamiento seguro o simplemente en memoria.
El compromiso de la ausencia de estado y la revocación
El principal atractivo de los JWT es que son stateless (sin estado). Cualquier servidor puede verificar un token sin necesidad de un estado compartido, lo que permite un escalado excepcional entre regiones y hace que el escalado horizontal sea trivial. El coste es que la revocación es genuinamente difícil. Un token firmado es válido hasta que expira, independientemente de si has eliminado al usuario, cambiado su rol o cerrado su sesión. No existe un registro central que se pueda borrar.
Puedes recuperar parte del control de la siguiente manera:
- Mantén los access tokens con una duración corta para que la ventana de revocación sea de minutos en lugar de días.
- Mantén una deny-list de valores
jtipara los casos excepcionales de cierre de sesión inmediato, verificándola en cada solicitud. Esto reintroduce el estado, así que mantenla pequeña y con tiempo de expiración. - Añade un claim de
token_versionpor usuario y rechaza los tokens cuya versión esté obsoleta. Cambiar la contraseña o el rol incrementaría este valor.
Este es el resumen honesto: los JWT sacrifican la facilidad de revocación en favor de la facilidad de escalado. Si necesitas revocación instantánea en todas partes, las cookies de sesión respaldadas por un almacén pueden encajar mejor, como se explica en Session Auth.
Confusión de algoritmos y alg none
Hay dos ataques lo suficientemente antiguos como para ser de libro de texto y, aun así, seguir encontrando víctimas.
El primero es alg: none. Un atacante edita el header a {"alg":"none"} y elimina la firma. Un verificador ingenuo que confía en el header acepta el token falsificado. El segundo es la confusión HS/RS. Un servicio que espera tokens RS256 es engañado para aceptar un token HS256 firmado con la clave pública RSA, la cual es pública por definición.
Ambos tienen la misma solución: el verificador decide el algoritmo, no el token.
// Good: the verifier decides.
await jwtVerify(token, secret, { algorithms: ["HS256"] });
// Bad: the token decides.
const { header } = decodeProtectedHeader(token);
await jwtVerify(token, secret, { algorithms: [header.alg] });
Rechaza alg: none rotundamente, nunca derives la clave de una fuente no confiable y trata el header como datos, no como instrucciones.
Scopes y autorización
La autenticación responde a quién es el usuario, y los scopes responden a qué puede hacer. Un claim de scope es una lista de permisos delimitados por espacios, y el middleware lo verifica antes de que se ejecute un handler.
export function requireScope(required: string) {
return (req, res, next) => {
const granted = String(req.user.scope ?? "").split(" ");
if (!granted.includes(required)) {
return res.status(403).json({ error: "insufficient_scope" });
}
next();
};
}
Mantén los scopes generales y estables, y haz que se cumplan en el servidor. Un token sin el scope correcto debe fallar con un 403, no un 401: el emisor está autenticado, simplemente no tiene permiso. Para modelos de acceso más complejos basados en roles y atributos, consulta RBAC.
JWT vs tokens opacos
Un JWT es un bearer token que lleva su propia validación. Un token opaco es una cadena aleatoria sin significado propio, por lo que el servidor debe consultarlo para obtener cualquier información.
Los tokens opacos ganan en términos de revocación y privacidad. Puedes eliminar la sesión al instante y el token no revela nada si llega a filtrarse. A cambio, requieren un viaje de ida y vuelta a la base de datos o caché en cada solicitud. Los JWT ganan en escalabilidad e independencia. Los servicios los verifican localmente y no necesitan un almacenamiento compartido, a costa de tener una ventana de tiempo antes de que la revocación sea efectiva.
Muchos sistemas en producción utilizan ambos: un access token en formato JWT para ganar velocidad y un refresh token opaco para mantener el control. Este modelo híbrido es el que implementan la mayoría de los proveedores de identidad hoy en día, y es una excelente opción por defecto cuando no sabes cuál elegir.
Rotación de claves con kid
Las claves de firma no deben ser eternas. La rotación limita el daño de una clave comprometida, y el campo de cabecera kid es lo que hace que la rotación sea invisible para los clientes: el verificador lee kid, selecciona la clave correspondiente y comprueba la firma.
import { SignJWT, jwtVerify, createLocalJWKSet } from "jose";
const jwks = createLocalJWKSet({
keys: [{ kty: "oct", kid: "2026-09", k: process.env.JWT_SECRET }],
});
// The verifier resolves the key from the header's kid.
await jwtVerify(token, jwks, { algorithms: ["HS256"] });
Cuando realices una rotación, publica la nueva clave junto a la antigua, firma los nuevos tokens con el nuevo kid y sigue verificando la clave antigua hasta que todos los tokens firmados con ella hayan expirado. Si la eliminas demasiado pronto, cerrarás la sesión de todos los usuarios activos a la vez. En el caso de claves asimétricas, publica un documento JWKS en una URL conocida y permite que los servidores de recursos lo almacenen en caché.
Duración de los tokens en la práctica
La expiración es un equilibrio entre seguridad y conveniencia. No existe una respuesta universal correcta, pero el esquema de un valor predeterminado sensato es consistente.
- Access tokens: de 5 a 15 minutos. Lo suficientemente cortos para que una filtración pierda valor rápidamente, y lo suficientemente largos para no tener que refrescar en cada solicitud.
- Refresh tokens: de 7 a 30 días, con rotación y una ventana deslizante (sliding window). Lo suficientemente duraderos para mantener a los usuarios conectados, pero lo suficientemente cortos para que un token abandonado eventualmente expire.
- Límite absoluto de sesión: de 30 a 90 días. Una edad máxima tras la cual el usuario debe autenticarse de nuevo, independientemente de con qué frecuencia refresque el token.
Si tus usuarios se quejan de que se cierra la sesión, la solución es un silent refresh más fluido, no un access token más largo. Un access token de 24 horas es una falla de revocación esperando a suceder.
Depurar un token sin confiar en él
Cuando una solicitud falla con un error 401, es probable que quieras ver los claims del token sin debilitar la verificación. Decodificar el token es seguro siempre y cuando trates el resultado como datos no confiables.
import { decodeJwt, decodeProtectedHeader } from "jose";
const header = decodeProtectedHeader(token);
const claims = decodeJwt(token);
console.log({ alg: header.alg, kid: header.kid });
console.log({
sub: claims.sub,
iss: claims.iss,
aud: claims.aud,
exp: new Date((claims.exp ?? 0) * 1000).toISOString(),
expired: (claims.exp ?? 0) * 1000 < Date.now(),
});
Los dos fallos que verás con más frecuencia son un desajuste de aud tras renombrar un servicio y un desajuste de iss al cambiar entre entornos. Ambos son problemas de configuración y ambos son invisibles hasta que imprimes los claims.
Probando tokens
Deberías poder probar una ruta autenticada sin necesidad de levantar un proveedor de identidad. Dado que un JWT es simplemente una cadena firmada, basta con un helper de prueba que firme un token con el mismo secreto de prueba.
import { SignJWT } from "jose";
import request from "supertest";
import app from "../app.js";
const secret = new TextEncoder().encode("test-secret");
async function tokenFor(scope = "read:posts") {
return new SignJWT({ scope })
.setProtectedHeader({ alg: "HS256" })
.setSubject("user_1")
.setIssuer("https://auth.example.com")
.setAudience("api.example.com")
.setExpirationTime("5m")
.sign(secret);
}
test("rejects a request without a token", async () => {
await request(app).get("/posts").expect(401);
});
test("accepts a request with a valid token", async () => {
const token = await tokenFor();
await request(app)
.get("/posts")
.set("authorization", `Bearer ${token}`)
.expect(200);
});
Prueba también los casos negativos: un token expirado, un token con el audience incorrecto y un token firmado con una clave diferente. Esas son las comprobaciones que te protegen, por lo que merecen tener cobertura tanto como el camino feliz (happy path).
Mejores prácticas
- Configura siempre
exp; mantén los tokens de acceso en 15 minutos o menos. - Fija el algoritmo en el verificador y rechaza
alg: none. - Valida
iss,aud,expynbf; nunca confíes en un claim que no hayas comprobado. - Mantén los secretos y las claves privadas fuera del control de versiones y cárgalos desde un gestor de secretos.
- Almacena los refresh tokens con hash y rotalos en cada uso.
- Mantén los claims pequeños y sin información sensible; el payload es legible.
- En los navegadores, prefiere cookies httpOnly o memoria sobre localStorage.
- Planifica la revocación mediante vidas útiles cortas, una deny-list de
jtio una versión del token. - Usa
josepara código nuevo; está basado en promesas y es portable entre runtimes.
Errores comunes
- Asumir que el payload está cifrado solo porque parece texto sin sentido.
- Leer los claims con
decodey tratarlos como si estuvieran verificados. - Permitir que el header
algdel token elija el algoritmo de verificación. - Aceptar un token sin verificar
aud, permitiendo que tokens de staging funcionen en producción. - Usar un secreto débil o compartido, o subirlo al repositorio.
- Establecer la expiración en 30 días porque refrescar el token es molesto.
- Guardar los tokens en localStorage y dar el trabajo por terminado.
- Incluir un rol en el token y nunca invalidarlo cuando el rol cambia.
- Tratar un error 401 y un 403 como si fueran el mismo error.
Próximos pasos
Los JWT son solo una herramienta dentro de un conjunto más amplio de gestión de identidades. La guía de OAuth 2.0 muestra cómo se obtienen realmente los tokens mediante la autorización delegada, Session Auth cubre la alternativa basada en cookies para cuando necesitas una revocación instantánea, y API Keys explica las credenciales de larga duración para clientes automáticos. Si quieres ver este código de verificación implementado en un servidor real, lee la sección de Node.js.