Los tutoriales de JWT suelen terminar en jwt.sign() y un 200 OK. La producción empieza donde termina el tutorial: renovación de token, revocación de sesión, almacenamiento en el cliente y los ataques que agarran una implementación ingenua.
Dos tokens, dos duraciones
Un access token de 15 minutos limita la ventana de exposición si el token se filtra. Un refresh token de 7 días renueva el access token sin pedir login de nuevo. El RFC 6749, sección 6, define ese par como un grant separado:
1. login -> access token (15 min) + refresh token (7 días)
2. access expira -> el cliente llama /auth/refresh con el cookie
3. refresh expira -> el usuario hace login de nuevo
Un access token largo ensancha la ventana de robo. Pedir login cada 15 minutos manda al usuario al soporte. El par corto y largo resuelve los dos lados.
Generación con firmas separadas
import jwt from 'jsonwebtoken'
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET
function generateTokens(user: { id: string; email: string }) {
const accessToken = jwt.sign(
{ sub: user.id, email: user.email, type: 'access' },
ACCESS_SECRET,
{ expiresIn: '15m' }
)
const refreshToken = jwt.sign(
{ sub: user.id, type: 'refresh' },
REFRESH_SECRET,
{ expiresIn: '7d' }
)
return { accessToken, refreshToken }
}
Un secreto único para los dos tokens arrastra la fuga de uno hacia el otro. La clave de firma sale de variables de entorno y no entra en commits.
El RFC 8725, Best Current Practice para JWT, recomienda fijar el algoritmo esperado al verificar. Sin ese pin, la implementación trata el campo alg del token como una instrucción, y un atacante arma un token con alg: none o cambia HS256 por RS256 usando la clave pública como secreto.
// el algoritmo viene de tu configuración, no del token
const payload = jwt.verify(token, ACCESS_SECRET, {
algorithms: ['RS256'],
issuer: 'https://api.example.com',
audience: 'web-app',
})
Access token en memoria, refresh token en cookie
- Access token: una variable en memoria JavaScript. localStorage guarda el token para que cualquier payload XSS lo lea después.
- Refresh token: cookie con
HttpOnly,SecureySameSite=Strict. La referencia de Set-Cookie en MDN describe cada flag: HttpOnly saca el cookie dedocument.cookie, Secure restringe el envío a HTTPS, SameSite lo bloquea en requests cross-site.
let accessToken: string | null = null
async function login(email: string, password: string) {
const res = await fetch('/api/auth/login', {
method: 'POST',
credentials: 'include',
body: JSON.stringify({ email, password }),
})
const data = await res.json()
accessToken = data.accessToken
}
async function apiRequest(url: string, options: RequestInit = {}) {
const res = await fetch(url, {
...options,
credentials: 'include',
headers: {
...options.headers,
Authorization: `Bearer ${accessToken}`,
},
})
if (res.status === 401) {
const renewed = await refreshAccessToken()
if (renewed) return apiRequest(url, options)
window.location.href = '/login'
}
return res
}
Renovación en el 401
async function refreshAccessToken(): Promise<boolean> {
try {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
})
if (!res.ok) return false
const data = await res.json()
accessToken = data.accessToken
return true
} catch {
return false
}
}
El cookie viaja en cada request, así que el refresh no depende de estado en el cliente. Dos llamadas simultáneas que llegan al endpoint de refresh al mismo tiempo chocan cuando la rotación está activa; serializa las renovaciones en una cola de un solo lugar por pestaña.
Revocación con lista negra y TTL
Un JWT verifica la firma sin consultar al servidor, y esa propiedad es la que impide la revocación automática. El RFC 8725 recomienda una lista negra para tokens revocados. En el logout, guarda el token con un TTL igual al tiempo que falta para su exp:
async function logout(token: string) {
const decoded = jwt.decode(token) as { exp: number }
const ttl = decoded.exp - Math.floor(Date.now() / 1000)
await redis.setex(`blacklist:${token}`, ttl, 'revoked')
}
async function verifyToken(token: string) {
const blacklisted = await redis.get(`blacklist:${token}`)
if (blacklisted) throw new Error('Token revoked')
return jwt.verify(token, ACCESS_SECRET, { algorithms: ['RS256'] })
}
El refresh token pide lo mismo en el logout: borra el cookie de refresh en el cliente y invalida el valor del refresh en el servidor. Sin eso, el refresh sobrevive al logout y emite access tokens para una sesión que el usuario cerró.
Rotación del refresh token
Cada uso del refresh emite un refresh nuevo e invalida el anterior. Reusar un refresh ya consumido avisa de que alguien lo copió, y la respuesta correcta es tirar abajo todo el árbol de sesión del usuario.
El flujo completo
El login entrega los dos tokens. La request lleva el access token en el header y el cookie de refresh. Un 401 dispara la renovación; cuando la renovación falla, el cliente manda al usuario a la página de login. El logout limpia los dos lados. El OWASP Authentication Cheat Sheet cubre el resto de la lista: HTTPS en cada ruta, expiración corta, hash adecuado de contraseña y límite de intentos.
Referencias
- RFC 7519, JSON Web Token (Jones, Bradley y Sakimura, 2015)
- RFC 7515, JSON Web Signature (2015)
- RFC 8725, Best Current Practice for JSON Web Tokens (2020)
- RFC 6749, sección 6, Refresh Token Grant (Hardt, 2012)
- node-jsonwebtoken (documentación de la librería)
- OWASP Authentication Cheat Sheet
¿Te gustó el contenido?
Construyo productos web y soluciones con IA de la manera correcta — arquitectura sólida, código sostenible y entrega real.
Hablemos