La mayoría de los tutoriales de JWT se quedan en jwt.sign y listo. En fintech eso no alcanza: un token robado de larga vida significa que un atacante puede mover dinero durante días antes de que alguien lo note.
En esta guía te muestro el patrón de producción: access tokens de 15 minutos, refresh tokens rotativos en cookies httpOnly y detección de reuso que revoca toda la familia de sesiones cuando un token se usa dos veces.
1. El Problema: Los Tokens de Larga Vida Son una Bomba
Un JWT es autocontenido y stateless, y por eso mismo es peligroso cuando vive demasiado. Quien tenga un access token válido ES el usuario hasta que expire — no hay sesión en el servidor para matar. OWASP es directo: vidas cortas, validación estricta de exp, iss y aud, y jamás aceptar alg none.
El Error Más Común
Emitir un access token de 24 horas o 7 días y guardarlo en localStorage. Una inyección XSS lee localStorage, exfiltra el token y el atacante tiene acceso total a la API durante días sin forma de revocarlo.
La solución tiene tres partes que funcionan juntas: access tokens que expiran en minutos, refresh tokens de vida más larga pero que rotan en cada uso, y almacenamiento que deja las credenciales duraderas fuera del alcance de JavaScript. Construyamos exactamente eso.
2. Los Conceptos Mínimos: Access, Refresh, Rotación
Antes de tocar código necesitás cuatro ideas claras. Son simples, pero si fallás en cualquiera todo el diseño se cae.
Access token
Vida corta · 5–15 min
Viaja en cada request a la API en el header Authorization. Su TTL corto es la ventana de daño: si lo roban, muere en minutos.
Refresh token
Vida larga · 7–30 días
Solo se usa contra POST /auth/refresh para emitir nuevos access tokens. Nunca va a endpoints de negocio ni es legible desde JavaScript.
Rotación
Un uso por token
Cada refresh invalida el refresh token anterior y emite un par nuevo. Un refresh token solo puede usarse una vez.
Detección de reuso
Alarma de robo
Si un token ya rotado aparece de nuevo, alguien lo copió. El servidor revoca toda la familia de tokens y fuerza un nuevo login.
Almacenamiento dividido
Cookie + memoria
Refresh token en cookie httpOnly, Secure, SameSite con scope a /auth/refresh. Access token en memoria (o cookie de vida corta). Jamás localStorage.
Claims que importan
exp · iss · aud · jti
Validá expiración, emisor y audiencia en cada verify, fijá el algoritmo y usá jti para trackear cada refresh token en tu store.
Vidas Recomendadas para Fintech
Access token: 15 minutos. Refresh token: 7 días con rotación y un tope absoluto de 30 días. Flujos bancarios o de salud: access tokens de 5 minutos y re-autenticación reforzada para operaciones sensibles.
3. Tutorial Paso a Paso: Rotación en Node.js
Usaremos jsonwebtoken (v9, la librería mantenida por Auth0 con jwt.sign y jwt.verify) sobre Express. El mismo patrón se porta a jose — el equivalente está al final.
Paso 1
Setup del proyecto
Instalá Express más jsonwebtoken y cookie-parser, y mantené dos secretos separados: uno para access tokens y otro para refresh tokens. Claves separadas implican que un secreto de access filtrado no puede forjar refresh tokens.
npm install express jsonwebtoken cookie-parser import express from "express"; import jwt from "jsonwebtoken"; import cookieParser from "cookie-parser"; import crypto from "crypto"; const app = express(); app.use(express.json()); app.use(cookieParser());
Paso 2
El login emite el par de tokens
Tras verificar credenciales, firmá un access token de 15 minutos y un refresh token de 7 días con jti único. El refresh va en una cookie blindada; solo el access token vuelve en el cuerpo JSON.
const accessToken = jwt.sign(
{ sub: user.id, role: user.role },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: "15m", audience: "fintech-api", issuer: "my-fintech" }
);
const tokenId = crypto.randomUUID();
sessions.set(tokenId, { userId: user.id });
const refreshToken = jwt.sign(
{ sub: user.id },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: "7d", audience: "fintech-api", issuer: "my-fintech", jwtid: tokenId }
);
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/auth/refresh",
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({ accessToken });Paso 3
Protegé rutas con verify estricto
Cada ruta protegida verifica firma, algoritmo, audiencia y emisor. Fijá algorithms en HS256 (o tu clave RS256) para que un atacante nunca pueda degradar el token a alg none.
function requireAuth(req, res, next) {
const header = req.headers.authorization || "";
const token = header.replace("Bearer ", "");
try {
req.user = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, {
algorithms: ["HS256"],
audience: "fintech-api",
issuer: "my-fintech"
});
return next();
} catch (err) {
return res.status(401).json({ message: "Invalid or expired token" });
}
}
app.get("/api/balance", requireAuth, (req, res) => {
res.json({ balance: getBalance(req.user.sub) });
});Paso 4
Rotá en cada refresh, detectá reuso
Este es el corazón del patrón. Buscá el jti en tu store (Redis en producción, un Map en este ejemplo). Un jti desconocido significa que el token ya fue rotado — tratalo como robo, revocá toda la familia y forzá un nuevo login.
app.post("/auth/refresh", (req, res) => {
const oldToken = req.cookies.refreshToken;
if (!oldToken) return res.status(401).json({ message: "Missing token" });
let payload;
try {
payload = jwt.verify(oldToken, process.env.REFRESH_TOKEN_SECRET, {
algorithms: ["HS256"],
audience: "fintech-api",
issuer: "my-fintech"
});
} catch (err) {
return res.status(401).json({ message: "Invalid token" });
}
if (!sessions.has(payload.jti)) {
revokeFamily(payload.sub); // reuso detectado: quemar todo
return res.status(401).json({ message: "Reuse detected" });
}
sessions.delete(payload.jti);
const tokenId = crypto.randomUUID();
sessions.set(tokenId, { userId: payload.sub });
const accessToken = jwt.sign({ sub: payload.sub },
process.env.ACCESS_TOKEN_SECRET, { expiresIn: "15m" });
const refreshToken = jwt.sign({ sub: payload.sub },
process.env.REFRESH_TOKEN_SECRET, { expiresIn: "7d", jwtid: tokenId });
res.cookie("refreshToken", refreshToken, {
httpOnly: true, secure: true, sameSite: "strict", path: "/auth/refresh"
});
res.json({ accessToken });
});Paso 5
El logout revoca en el servidor
Limpiar la cookie no alcanza — un token copiado seguiría verificando. Borrá el jti del store para que el refresh muera al instante; el access token expira solo en minutos.
app.post("/auth/logout", (req, res) => {
const token = req.cookies.refreshToken;
if (token) {
const decoded = jwt.decode(token);
if (decoded && decoded.jti) sessions.delete(decoded.jti);
}
res.clearCookie("refreshToken", { path: "/auth/refresh" });
res.json({ message: "Logged out" });
});Tip: preferí jose en proyectos nuevos
La librería jose (de panva) no tiene dependencias, es ESM nativa y corre igual en Node, edge runtimes y browsers. Su API es new jose.SignJWT({...}).setProtectedHeader({ alg: 'HS256' }).setExpirationTime('15m').sign(secret) para firmar y jose.jwtVerify(token, secret, { audience, issuer }) para verificar.
4. jsonwebtoken vs jose: ¿Cuál Librería en 2026?
Ambas librerías emiten tokens válidos, pero vienen de épocas distintas. Así elijo entre ellas para trabajo en producción.
📦 jsonwebtoken (Auth0)
El default probado en batalla
Más de 18k estrellas, API sincrónica jwt.sign / jwt.verify, soporte HS256/RS256/ES256. Ideal si tu codebase es CommonJS o ya dependés de ella.
Chequeos manuales de claims
Pasás expiresIn, audience e issuer como opciones y algorithms en verify. Sin helpers de rotación — el store y la detección de reuso los construís vos, como en esta guía.
Cuidado con los footguns
Pasá siempre algorithms explícito en verify y nunca uses jwt.decode para decisiones de auth — decode saltea la verificación de firma por completo.
✨ jose (panva)
El estándar ESM moderno
Cero dependencias, WebCrypto por debajo, el mismo código corre en Node 20+, Cloudflare Workers, Deno y browsers. Mi elección para proyectos greenfield.
API estilo builder
SignJWT encadenado con setIssuer, setAudience y setExpirationTime; jwtVerify valida claims en una sola llamada. Menos misconfiguraciones silenciosas.
Mismo patrón de rotación
jose tampoco rota por vos — el store de refresh, el trackeo de jti y la detección de reuso de la sección 3 aplican igual.
5. Dónde Vive Cada Token: Cookies Bien Hechas
El almacenamiento es donde fallan la mayoría de las implementaciones. La regla es simple: las credenciales duraderas jamás deben ser legibles por JavaScript, y las cookies deben tener el scope más angosto posible.
httpOnly + Secure + SameSite
La cookie de refresh necesita las tres flags: httpOnly bloquea lecturas XSS, Secure la restringe a HTTPS, SameSite=strict bloquea CSRF. Este trío es innegociable según la guía actual de OWASP.
Cookies con scope de path
Seteá path en /auth/refresh para que el browser solo envíe el refresh token al endpoint de refresh — nunca a cada llamada de la API. Superficie de exposición mínima por diseño.
Access token en memoria
Guardá el access token de 15 minutos en una variable de módulo y renoválo con silent refresh al recargar. La memoria muere con la pestaña, que es exactamente lo que querés.
Jamás localStorage
localStorage y sessionStorage son legibles por cualquier script inyectado. Un solo XSS persistente y cada token que guardaste ahí pasa al atacante.
Apps nativas difieren
En mobile no hay cookies httpOnly: usá el almacenamiento seguro del SO (Keychain, Keystore) para refresh tokens y access tokens efímeros en memoria.
6. Rotación en Producción: Los Tres Ritmos
La rotación no es un solo endpoint — es un ciclo de vida. Estos son los tres ritmos que corro en cada API fintech que entrego.
⚡ En cada refresh (automático)
- 1. Verificar firma, exp, aud, iss y algoritmo antes que nada
- 2. Borrar el jti viejo y emitir un par fresco — un uso por refresh token, sin excepciones
- 3. Ante un jti desconocido: revocar toda la familia y devolver 401 para forzar re-login
🪟 Ventanas de sesión
- Refresh deslizante: cada rotación extiende la sesión hasta 7 días de actividad
- Tope absoluto: 30 días máximo aun con actividad, luego re-autenticación completa obligatoria
- Timeout de inactividad: 30 minutos sin refreshes matan la sesión en flujos fintech sensibles
🛡️ Higiene operativa
- Rotación de claves: rotá secretos de firma con período de gracia dual para que los access viejos drenen solos
- Auditoría del store jti: alertá en cada evento de detección de reuso — cada uno es un probable robo de token
- Secretos separados: los access y refresh tokens jamás deben compartir clave de firma
7. Errores Comunes que Hackean Fintechs
Tras revisar varias implementaciones de auth fintech, las mismas fallas aparecen una y otra vez. Contrastá tu codebase con ambas listas antes de deployar.
✅ Publicá esto
- • Access tokens de 15 minutos + refresh tokens rotativos de 7 días
- • Cookies httpOnly, Secure, SameSite=strict con scope a /auth/refresh
- • Detección de reuso que revoca toda la familia de tokens
- • Algorithms explícito en cada verify más validación de aud/iss
- • Logout server-side que borra el jti del store
- • Secretos de firma separados para access y refresh
❌ Jamás publiques esto
- • Access tokens de 24 horas sin estrategia de refresh
- • Tokens en localStorage o sessionStorage
- • Aceptar alg none u omitir la opción algorithms
- • Poner passwords, tarjetas o PII en el payload del JWT
- • Reusar un secreto para access y refresh
- • Logout que solo limpia la cookie del cliente
Regla de oro
Un refresh token es un password que se renueva solo: un solo uso, invisible para JavaScript y revocado ante el primer indicio de reuso. Si tu implementación lo trata con la liviandad de un access token, la rotación es puro teatro.
Conclusión
Access tokens de vida corta limitan el radio de explosión, la rotación con detección de reuso convierte el robo en alarma, y las cookies httpOnly mantienen las credenciales duraderas lejos de scripts inyectados. Ninguna de las tres funciona sola — juntas son el piso que OWASP espera de cualquier API que mueve dinero.
Partí del código de la sección 3, respaldalo con un store jti en Redis en producción y conectá los eventos de detección de reuso a tu alertamiento. Tu yo de incident-response te lo va a agradecer — y si querés esto implementado en tu API fintech, ya sabés dónde encontrarme.
La Receta: Resumen
Tokens
- • Access: 15 min, header Bearer
- • Refresh: 7 días, rotativo
- • Reuso → revocar familia
Almacenamiento
- • Cookie httpOnly + Secure
- • SameSite=strict, path /auth
- • Access token en memoria
Operación
- • Secretos de firma separados
- • Tope absoluto de 30 días
- • Alertar en eventos de reuso



