🧱 Full Stack 🛡️ Seguridad

Autenticación end-to-end en una app full-stack: guía práctica con sesiones y JWT

No es un debate religioso: sesiones y JWT reparten el riesgo de forma distinta. Repasamos cómo funciona cada modelo, por qué localStorage es más peligroso de lo que parece y qué patrón domina en 2026.

📅 10 de septiembre de 2026 ⏱️ 12 min de lectura ✍️ Equipo ProgramacionWebs

Cada vez que un equipo arranca una aplicación full-stack nueva llega el mismo momento: hay que decidir cómo va a recordar el backend quién es cada usuario entre petición y petición. HTTP no tiene memoria propia, así que alguien tiene que inventársela. Las dos respuestas más habituales —sesión con cookie httpOnly o JWT guardado en el cliente— no son intercambiables ni una sustituye estrictamente a la otra: reparten el riesgo de forma distinta, y esa diferencia solo se entiende bajando al detalle de qué puede hacer un atacante con cada pieza si algo sale mal.

Este artículo no defiende un bando. Defiende entender exactamente qué estás asumiendo cuando eliges uno u otro, y cómo se conecta con el resto de una arquitectura full-stack real: frontend, backend, almacén de sesiones y el propio navegador en medio.

Dos modelos, un mismo problema

Autenticar a alguien una sola vez (comprobar usuario y contraseña, o validar una passkey) es la parte fácil — el proceso concreto de OAuth 2.1 y WebAuthn ya lo cubrimos en autenticación moderna con OAuth y passkeys. Lo difícil es lo que viene después: mantener esa identidad verificada disponible en cada petición posterior, sin pedir credenciales de nuevo, durante minutos, horas o días.

Hay solo dos formas de resolver esto:

  1. El servidor recuerda. Guarda un registro de “esta sesión pertenece a este usuario” en algún almacén (memoria, Redis, base de datos), y le entrega al navegador únicamente un identificador opaco de esa sesión. Esto es el modelo de sesión con cookie.
  2. El cliente recuerda, pero de forma verificable. El servidor firma un documento con la identidad y los permisos del usuario, se lo entrega al cliente, y en cada petición el cliente lo devuelve. El servidor no guarda nada: solo verifica la firma. Esto es el modelo de JWT (JSON Web Token).

La diferencia no es de seguridad per se — es de dónde vive el estado y qué puede hacer alguien que roba la credencial.

Sesiones con cookies httpOnly: el modelo aburrido que sigue funcionando

El servidor genera un identificador de sesión con suficiente entropía (OWASP recomienda un mínimo de 64 bits), lo asocia a un registro en un almacén compartido y se lo envía al navegador en una cookie con un conjunto concreto de atributos:

// server/session.ts (Node + Redis)
import { randomBytes } from 'node:crypto'
import { redis } from './redis'

const SESSION_TTL_SECONDS = 60 * 60 * 8 // 8 horas

export async function createSession(userId: string) {
  const sessionId = randomBytes(32).toString('hex')
  await redis.set(`session:${sessionId}`, JSON.stringify({ userId }), {
    EX: SESSION_TTL_SECONDS,
  })
  return sessionId
}

export function setSessionCookie(res: Response, sessionId: string) {
  res.headers.append(
    'Set-Cookie',
    [
      `__Host-session=${sessionId}`,
      'HttpOnly',
      'Secure',
      'SameSite=Lax',
      'Path=/',
      `Max-Age=${SESSION_TTL_SECONDS}`,
    ].join('; ')
  )
}

El prefijo __Host- obliga a que la cookie lleve Secure, no declare Domain y use Path=/, lo que impide que un subdominio comprometido pueda sobrescribirla — una protección de defensa en profundidad barata que cuesta cero implementar y que documenta bien MDN sobre atributos de cookies. HttpOnly es la pieza que de verdad importa para este artículo: impide que cualquier document.cookie desde JavaScript lea el valor de la cookie, incluido JavaScript malicioso inyectado por una vulnerabilidad XSS.

En cada petición posterior, el navegador adjunta la cookie automáticamente (para eso están las cookies) y el servidor solo tiene que resolver el identificador contra el almacén:

export async function getSession(sessionId: string | undefined) {
  if (!sessionId) return null
  const raw = await redis.get(`session:${sessionId}`)
  return raw ? (JSON.parse(raw) as { userId: string }) : null
}

Lo que gana este modelo es revocación instantánea y real: borrar la clave en Redis invalida la sesión en el acto, en cualquier dispositivo, sin esperar a que expire nada. Lo que exige a cambio es un almacén de sesiones accesible desde cualquier instancia del backend — no es gratis en una arquitectura con varios servidores sin estado compartido, aunque Redis resuelve esto sin complicación real en la inmensa mayoría de los casos.

El otro modelo nació de una necesidad real: cuando el frontend y el backend no comparten dominio (una SPA servida desde un CDN hablando con una API en otro subdominio, o un cliente móvil sin concepto de cookie de navegador), depender de que el navegador adjunte una cookie automáticamente deja de ser tan simple. La solución habitual: el backend firma un JWT tras el login, el cliente lo guarda él mismo y lo adjunta a mano en cada petición.

// server/auth.ts
import jwt from 'jsonwebtoken'

export function issueAccessToken(userId: string, role: string) {
  return jwt.sign({ sub: userId, role }, process.env.JWT_SECRET!, {
    expiresIn: '15m',
  })
}
// cliente: guardar y usar el token
localStorage.setItem('accessToken', token)

fetch('/api/orders', {
  headers: { Authorization: `Bearer ${localStorage.getItem('accessToken')}` },
})

Es simple, funciona igual de bien entre dominios distintos, y no exige un almacén de sesiones en el servidor: el propio token lleva la identidad firmada. Esa simplicidad es real y explica por qué tantos tutoriales lo enseñan como el camino por defecto.

El problema no es el JWT en sí — es dónde lo has guardado. localStorage (y sessionStorage) son accesibles desde cualquier JavaScript que se ejecute en el origen de tu página, sin excepción. Eso incluye el código que tú escribiste, pero también cualquier script que consiga ejecutarse ahí por una vulnerabilidad XSS: una dependencia de npm comprometida, un campo de comentarios sin sanitizar, una librería de analítica de terceros con un bug. En el momento en que eso ocurre, ese script puede leer localStorage.getItem('accessToken') con exactamente los mismos privilegios que tu propia aplicación, y exfiltrar el token a un servidor externo sin que el usuario note nada.

Esto no es una opinión aislada: es la razón por la que tanto la guía de la IETF sobre aplicaciones basadas en navegador como el propio cheat sheet de OWASP desaconsejan explícitamente guardar tokens de autenticación en localStorage o sessionStorage, precisamente porque “una sola vulnerabilidad XSS expone todos los tokens”.

Entre “todo en una cookie httpOnly clásica” y “todo en localStorage” hay un patrón híbrido que se ha convertido en el estándar de facto para SPAs y clientes móviles que sí necesitan un token portable: el access token vive solo en memoria (una variable de módulo, un store de estado en cliente — nunca en almacenamiento persistente) y el refresh token vive en una cookie HttpOnly, Secure, SameSite.

// cliente: el access token nunca toca localStorage
let accessToken: string | null = null

export function setAccessToken(token: string) {
  accessToken = token // vive en memoria del proceso JS, se pierde al recargar
}

export async function apiFetch(path: string, init: RequestInit = {}) {
  const res = await fetch(path, {
    ...init,
    headers: { ...init.headers, Authorization: `Bearer ${accessToken}` },
    credentials: 'include', // para que viaje la cookie del refresh token
  })
  if (res.status === 401) {
    await refreshAccessToken() // usa la cookie httpOnly, no algo que el JS lea
    return apiFetch(path, init)
  }
  return res
}

La lógica: si un XSS consigue ejecutarse, puede robar el access token que esté en memoria en ese instante concreto — pero ese token tiene una vida útil de minutos, no de días, y el atacante no puede leer la cookie HttpOnly que permitiría renovarlo indefinidamente. El daño queda acotado en el tiempo incluso en el peor escenario. Es exactamente el enfoque que recomienda la guía de la IETF para aplicaciones de navegador, y en su versión más estricta —el patrón Backend-for-Frontend (BFF)— ni siquiera el access token llega al navegador: todo el manejo de tokens OAuth ocurre en un componente de servidor, y al cliente solo llega una cookie de sesión opaca, exactamente igual que en el modelo de sesiones del primer apartado.

Refresh tokens: rotación, detección de reuso y revocación

Un access token de vida corta (5-15 minutos es el rango habitual en 2026) obliga a resolver cómo renovarlo sin pedir credenciales de nuevo cada pocos minutos. Ahí entra el refresh token, definido originalmente en RFC 6749 como una credencial de vida más larga que el servidor de autorización puede usar para obtener un nuevo access token sin involucrar al usuario otra vez.

La práctica que se ha vuelto no negociable es la rotación con detección de reuso: cada vez que se usa un refresh token para pedir un nuevo access token, el servidor emite también un refresh token nuevo e invalida el anterior. Si alguien intenta reutilizar un refresh token ya usado —porque lo robó y el usuario legítimo ya lo rotó primero, o al revés—, eso es una señal inequívoca de robo, y la respuesta correcta es revocar toda la familia de tokens derivada de esa sesión, no solo el token concreto reutilizado.

// server/refresh.ts (esquema simplificado)
export async function rotateRefreshToken(oldToken: string) {
  const record = await db.refreshToken.findUnique({ where: { token: oldToken } })

  if (!record) {
    // token desconocido o ya rotado antes: posible robo, corta la familia entera
    throw new Error('invalid_refresh_token')
  }

  if (record.usedAt) {
    // reuso de un token ya consumido: señal de robo confirmada
    await db.refreshToken.updateMany({
      where: { familyId: record.familyId },
      data: { revokedAt: new Date() },
    })
    throw new Error('refresh_token_reuse_detected')
  }

  await db.refreshToken.update({
    where: { token: oldToken },
    data: { usedAt: new Date() },
  })

  const newToken = generateRefreshToken()
  await db.refreshToken.create({
    data: { token: newToken, familyId: record.familyId, userId: record.userId },
  })

  return { accessToken: issueAccessToken(record.userId), refreshToken: newToken }
}

El refresh token, igual que el access token del patrón híbrido, debe viajar en una cookie HttpOnly, Secure, con un Path restringido al endpoint de refresco (por ejemplo /api/auth/refresh) para que ni siquiera se envíe innecesariamente en cada petición normal a la API.

Dónde vive cada pieza en una arquitectura full-stack real

sequenceDiagram
participant N as Navegador
participant F as Frontend (SPA/SSR)
participant A as API backend
participant R as Redis / DB (sesiones o familias de refresh token)
N->>A: Login (credenciales o passkey)
A->>R: Crea sesión / familia de refresh token
A-->>N: Cookie HttpOnly (sesión o refresh token)
A-->>F: Access token en memoria (solo si aplica modelo híbrido)
F->>A: Peticiones con Authorization: Bearer o cookie automática
A->>R: Verifica sesión / valida firma del JWT
A-->>F: Respuesta autorizada

En una arquitectura donde el mismo servidor renderiza HTML y sirve la API —el caso típico de un monolito full-stack modular con Next.js, Astro o Laravel— la sesión con cookie httpOnly es casi siempre la opción por defecto correcta: no hay problema de dominio cruzado que resolver, y las Server Actions y funciones de servidor ya asumen que la identidad se deriva de la sesión, nunca del payload que envía el cliente — el mismo principio de fondo que rige la validación de un refresh token.

Cuando frontend y backend se despliegan como piezas independientes (una SPA en un CDN, una API en otro dominio, quizá un cliente móvil compartiendo la misma API), el patrón híbrido de access token en memoria más refresh token en cookie es el que reparte mejor el riesgo sin renunciar a la portabilidad que ese tipo de arquitectura necesita.

Errores más comunes en producción

Recomendación práctica

Si frontend y backend comparten origen y no hay cliente nativo de por medio, empieza por una sesión con cookie HttpOnly, Secure, SameSite=Lax, con el identificador en Redis o en tu base de datos principal: es el modelo con menos piezas móviles y revocación instantánea real. Si necesitas un token portable para una SPA en otro dominio, una app móvil o una API pública, adopta el patrón híbrido —access token de vida corta en memoria, refresh token con rotación y detección de reuso en una cookie HttpOnly— y trata cualquier tentación de usar localStorage como lo que es: una simplificación que traslada todo el riesgo de una sola vulnerabilidad XSS a la cuenta completa del usuario.

Compartir