La mayoría de los tutoriales de JWT terminan con jwt.sign() y un 200 OK. Producción empieza donde termina el tutorial: cómo renovar tokens, cómo invalidar sesiones, cómo almacenar tokens en el cliente y cómo protegerse contra ataques.

Por qué JWT + Refresh Token

Access tokens cortos (15 min) limitan la ventana de exposición si un token es comprometido. Refresh tokens largos (7 días) permiten renovar el access token sin pedir login de nuevo. El ciclo:

1. Usuario hace login → recibe access token (15min) + refresh token (7 días)
2. Access token expira → cliente usa refresh token para renovar
3. Refresh token expira → usuario hace login nuevamente

Sin refresh tokens, necesitas access tokens largos (peligroso) o pedir login cada 15 minutos (malo para la experiencia).

Generación de tokens

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 }
}

Dos secrets diferentes. Si el refresh secret se filtra, el access token no se ve afectado. Nunca uses el mismo secret para ambos.

Storage seguro en el cliente

El almacenamiento del access token en el cliente se debate, pero la práctica segura es:

  • Access token: memoria JavaScript (variable). No localStorage (vulnerable a XSS), no cookie HttpOnly (comparte entre pestañas, pero funciona para muchos casos).
  • Refresh token: cookie HttpOnly, Secure, SameSite=Strict. JavaScript nunca debe acceder al refresh token.
// En el login, almacena access token en memoria
let accessToken: string | null = null

async function login(email: string, password: string) {
  const res = await fetch('/api/auth/login', {
    method: 'POST',
    credentials: 'include',  // envía cookies
    body: JSON.stringify({ email, password })
  })
  const data = await res.json()
  accessToken = data.accessToken  // en memoria, no en storage
}

// En cada request, usa el access token
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) {
    // Access token expirado: intenta renovar
    const renewed = await refreshAccessToken()
    if (renewed) {
      return apiRequest(url, options)  // intenta nuevamente
    }
    window.location.href = '/login'
  }

  return res
}

Refresh token flow

async function refreshAccessToken(): Promise<boolean> {
  try {
    const res = await fetch('/api/auth/refresh', {
      method: 'POST',
      credentials: 'include'  // envía cookie HttpOnly
    })

    if (!res.ok) return false

    const data = await res.json()
    accessToken = data.accessToken
    return true
  } catch {
    return false
  }
}

Invalidación de tokens

JWT por sí solo no soporta invalidación (es stateless). Para invalidar sesiones, mantén una lista de tokens revocados en el servidor:

// Al hacer logout, almacena el token en la blacklist
async function logout(token: string) {
  const decoded = jwt.decode(token)
  const ttl = decoded.exp - Math.floor(Date.now() / 1000)

  // Almacena en la blacklist con TTL igual al tiempo restante del token
  await redis.setex(`blacklist:${token}`, ttl, 'revoked')
}

// Middleware verifica la blacklist
async function verifyToken(token: string) {
  const isBlacklisted = await redis.get(`blacklist:${token}`)
  if (isBlacklisted) throw new Error('Token revoked')

  return jwt.verify(token, ACCESS_SECRET)
}

Protecciones obligatorias

  • HTTPS en todas las rutas. Sin excepción. Tokens en HTTP son interceptados.
  • HttpOnly + Secure + SameSite en cookies de refresh.
  • Nunca almacenar tokens en localStorage. Vulnerable a XSS.
  • Validar iss, aud y exp en cada request. No confíes solo en la firma.
  • Usar algoritmo fuerte (RS256) en producción. HS256 funciona pero es menos seguro para distribuido.

El flujo completo

Login → access + refresh tokens. Request con access token. Si 401, intenta refresh. Si refresh falla, redirige a login. Logout invalida ambos tokens. Refresh tokens corren en background antes de expirar.

Esto es el mínimo para autenticación JWT en producción. Todo lo que quede por debajo de esto es prototype, no sistema de autenticación.