Volver al blog
ReactNext.jsAutenticaciónJWTGestión de sesionesCSRF

Autenticación y gestión de sesiones en React y Next.js: una guía centrada en la seguridad para 2026

Introducción

La autenticación es el límite de seguridad más crítico en cualquier aplicación web. Y, sin embargo, también es uno de los que más se configuran mal. Una encuesta sobre 500 codebases de startups basadas en React descubrió que más del 60% tenía al menos una vulnerabilidad relacionada con la autenticación — desde JWT almacenados en localStorage hasta tokens CSRF ausentes en sesiones basadas en cookies.

No es un problema teórico. Los tokens de sesión robados representan casi el 30% de todas las brechas de seguridad en aplicaciones web, y las consecuencias van desde la toma de control de cuentas hasta la exfiltración completa de datos.

En esta guía aprenderás los patrones concretos para implementar autenticación segura en aplicaciones React y Next.js. Cubriremos las mejores prácticas con JWT, la configuración segura de cookies, la prevención de CSRF, la gestión del ciclo de vida de las sesiones y una lista de verificación de auditoría que puedes usar en tu próxima revisión de código.

Los dos enfoques principales: autenticación con JWT frente a sesiones

Las aplicaciones React modernas usan generalmente una de estas dos estrategias de autenticación:

1. Autenticación JWT sin estado

El servidor emite un token firmado (JWT) que contiene las declaraciones (claims) del usuario. El cliente envía este token con cada petición, normalmente en una cabecera Authorization: Bearer ***. El servidor valida la firma y extrae la identidad del usuario sin necesidad de consultar un almacén de sesiones.

Errores comunes:

  • Almacenar el JWT en localStorage (vulnerable a XSS)
  • Usar una caducidad del token excesivamente larga (horas o días)
  • Sin rotación del token de refresco (refresh token)
  • Sin mecanismo de revocación de tokens

2. Autenticación basada en sesiones con cookies

El servidor crea una sesión, la almacena en el lado del servidor (en Redis, una base de datos o memoria) y emite un ID de sesión al cliente como cookie HTTP-only. El servidor consulta la sesión en cada petición.

Errores comunes:

  • Faltan los flags de cookie SameSite y Secure
  • Sin protección CSRF
  • IDs de sesión predecibles
  • Sin caducidad ni rotación de sesiones

Ninguno de los dos enfoques es inherentemente más seguro: ambos son vulnerables a vectores de ataque diferentes. La elección correcta depende de los requisitos de tu aplicación.

Implementación segura de JWT para aplicaciones React

Dónde almacenar el token

Es la pregunta más debatida en la autenticación con React. Estas son tus opciones:

| Almacenamiento | Resistencia a XSS | Resistencia a CSRF | Funciona con APIs | |---------|---------------|-----------------|-----------------| | localStorage | [X] Vulnerable | [V] Inmune | [V] Sí | | sessionStorage | [X] Vulnerable | [V] Inmune | [V] Sí | | Cookie HTTP-only | [V] Seguro | [X] Necesita CSRF | [V] Sí | | Variable en memoria | [V] Seguro | [V] Inmune | [X] Se pierde al recargar |

El veredicto: si usas JWT, almacena el token de acceso en memoria y el token de refresco en una cookie HTTP-only. Así obtienes lo mejor de ambos mundos: el token de acceso nunca se persiste en almacenamiento (a salvo de XSS), mientras que el token de refresco se beneficia de los flags de seguridad de las cookies.

tsx
// [V] Patrón seguro: token de acceso en memoria, token de refresco en cookie HTTP-only

let accessToken: string | null = null;

export function getAccessToken(): string | null {
  return accessToken;
}

export async function login(email: string, password: string) {
  const res = await fetch('/api/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });

  if (!res.ok) throw new Error('Login failed');

  const { token } = await res.json();
  accessToken = token; // Almacenado solo en memoria
  // El token de refresco llega como cookie HTTP-only (la establece el servidor)
}

export async function refreshAccessToken(): Promise<string | null> {
  const res = await fetch('/api/auth/refresh', {
    method: 'POST',
    credentials: 'include', // Envía la cookie de refresco HTTP-only
  });

  if (!res.ok) {
    accessToken = null;
    return null;
  }

  const { token } = await res.json();
  accessToken = token;
  return token;
}

Nunca almacenes JWT en localStorage. Si existe una vulnerabilidad XSS en cualquier parte de tu aplicación — incluso en una dependencia de terceros — un atacante puede leer localStorage y robar el token de todos los usuarios.

Tokens de acceso de corta duración

Los tokens de acceso deberían vivir como máximo 5-15 minutos. ¿Por qué? Porque si un token es robado (vía XSS, una dependencia comprometida o un ataque MITM), solo es útil durante una ventana corta. El token de refresco, almacenado de forma segura en una cookie HTTP-only, puede rotarse en cada uso para limitar la exposición.

ts
// Lado del servidor: configuración del JWT
const accessTokenConfig = {
  expiresIn: '15m',        // Corta duración
  algorithm: 'RS256',      // Firma asimétrica (preferible a HS256)
};

const refreshTokenConfig = {
  expiresIn: '7d',         // Más larga, pero acotada
};

Rotación del token de refresco

Cada vez que se usa un token de refresco para obtener un nuevo token de acceso, rota el token de refresco. Esto significa que los tokens de refresco antiguos quedan invalidados inmediatamente. Si un atacante roba un token de refresco pero el usuario legítimo ya lo ha usado, el token antiguo deja de funcionar.

ts
// Lado del servidor: rotación del token de refresco
async function rotateRefreshToken(userId: string, oldToken: string) {
  // 1. Verifica que el token antiguo existe en la base de datos
  const stored = await db.refreshTokens.findUnique({
    where: { token: oldToken, userId }
  });

  if (!stored) {
    // Reutilización de token detectada: ¡alguien lo ha robado!
    await invalidateAllUserSessions(userId);
    throw new Error('Refresh token reuse detected');
  }

  // 2. Elimina el token antiguo
  await db.refreshTokens.delete({ where: { id: stored.id } });

  // 3. Emite un token nuevo
  const newToken = crypto.randomBytes(64).toString('hex');
  await db.refreshTokens.create({
    data: {
      token: newToken,
      userId,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
    },
  });

  return newToken;
}

Revocación de tokens

Los JWT no tienen estado: una vez emitidos, son válidos hasta que caducan. Para escenarios en los que necesitas revocación inmediata (cambio de contraseña, compromiso de la cuenta, cierre de sesión del usuario), mantén una lista de denegación (deny list) de IDs de token revocados (claim jti):

ts
// Al cerrar sesión o cambiar la contraseña
async function revokeToken(jti: string, expiresAt: Date) {
  await db.revokedTokens.create({
    data: {
      jti,
      expiresAt, // Limpieza tras la caducidad natural del token
    },
  });
}

// Comprobación en middleware
async function isTokenRevoked(jti: string): Promise<boolean> {
  const revoked = await db.revokedTokens.findUnique({ where: { jti } });
  return revoked !== null;
}

Configuración segura de cookies en Next.js

Si usas autenticación basada en sesiones o almacenas tokens de refresco en cookies, la configuración de las cookies es tu primera línea de defensa.

ts
// Ruta de API de Next.js: establecimiento de una cookie de sesión segura
import { cookies } from 'next/headers';

export async function POST(request: Request) {
  const body = await request.json();
  const session = await createSession(body.email, body.password);
  const cookieStore = await cookies();

  cookieStore.set('session_id', session.id, {
    httpOnly: true,       // No accesible desde JavaScript
    secure: true,         // Solo HTTPS
    sameSite: 'lax',      // Protección CSRF
    path: '/',
    maxAge: 60 * 60 * 24 * 7, // 7 días
    priority: 'high',     // Chrome: señala su importancia
  });

  return Response.json({ user: session.user });
}

Flags de cookie explicados

| Flag | Valor | Qué protege | |------|-------|-----------------| | httpOnly | true | Impide el acceso desde JavaScript: evita que el XSS lea la cookie | | secure | true | Solo se envía por HTTPS: evita la captura de tráfico en la red | | sameSite | lax o strict | Previene CSRF: el navegador no envía la cookie en peticiones entre sitios | | domain | (sin establecer) | Restringe al origen exacto: no uses un dominio comodín | | maxAge | Acotado | Ventana de sesión: cuanto más corto, mejor |

¿Por qué SameSite=Lax?

SameSite=Lax permite que la cookie se envíe en navegaciones de nivel superior (al hacer clic en un enlace hacia tu sitio), pero no en peticiones entre sitios incrustadas (etiquetas de imagen, iframes, fetch desde otro origen). Esto previene los ataques CSRF más comunes manteniendo una experiencia de usuario usable.

Usa SameSite=Strict para acciones sensibles (cambio de contraseña, confirmación de pago) en las que quieras obligar al usuario a navegar directamente a tu sitio.

Protección CSRF en React y Next.js

Incluso con SameSite=Lax, hay casos límite en los que el CSRF puede tener éxito. Implementa siempre tokens CSRF como medida de defensa en profundidad.

Patrón de cookie de doble envío (Double-Submit Cookie)

El servidor establece un token CSRF aleatorio como cookie no HTTP-only. El cliente lee esta cookie y envía su valor en una cabecera personalizada. El servidor verifica que ambos coinciden.

ts
// Middleware de Next.js: generación de tokens CSRF
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const response = NextResponse.next();

  // Omite GET, HEAD, OPTIONS
  if (['GET', 'HEAD', 'OPTIONS'].includes(request.method)) {
    return response;
  }

  const csrfCookie = request.cookies.get('csrf-token');
  const csrfHeader = request.headers.get('x-csrf-token');

  if (!csrfCookie || !csrfHeader || csrfCookie.value !== csrfHeader) {
    return new NextResponse('CSRF validation failed', { status: 403 });
  }

  return response;
}

export const config = {
  matcher: '/api/:path*',
};
tsx
// Cliente React: envío del token CSRF con las mutaciones
async function getCsrfToken(): Promise<string> {
  // Lee la cookie (no es HTTP-only)
  const match = document.cookie.match(/(?:^|;\s*)csrf-token=([^;]*)/);
  return match ? match[1] : '';
}

async function apiPost(url: string, body: unknown) {
  const csrfToken = await getCsrfToken();

  return fetch(url, {
    method: 'POST',
    credentials: 'include',
    headers: {
      'Content-Type': 'application/json',
      'x-csrf-token': csrfToken,
    },
    body: JSON.stringify(body),
  });
}

Gestión del ciclo de vida de las sesiones

Una sesión segura no es solo cómo se crea: es cómo vive y muere.

Lista de verificación de creación de sesiones

  • [ ] ID de sesión generado con una fuente aleatoria criptográficamente segura (crypto.randomUUID() o crypto.randomBytes())
  • [ ] Sesión almacenada en el lado del servidor (Redis o base de datos), nunca expuesta al cliente
  • [ ] El cliente recibe solo una referencia de sesión (cookie cifrada)
  • [ ] Sesión vinculada al ID de usuario, dirección IP (opcional) y user agent (opcional)

Rotación de sesiones

Tras el inicio de sesión, rota siempre el ID de sesión. Esto previene los ataques de fijación de sesión (session fixation), en los que un atacante establece un ID de sesión conocido antes de que el usuario inicie sesión.

ts
// Tras un inicio de sesión correcto
export async function login(req, res) {
  const user = await authenticateUser(req.body);

  // Destruye la sesión existente
  await destroySession(req.cookies.session_id);

  // Crea una sesión nueva con un ID fresco
  const newSession = await createSession(user.id);

  // Emite una cookie nueva
  res.setCookie('session_id', newSession.id, secureCookieOptions);
}

Tiempos de espera de inactividad y absolutos

Dos valores de tiempo de espera son mejores que uno:

| Tiempo de espera | Qué hace | Recomendación | |---------|-------------|---------------| | De inactividad (idle) | Se reinicia con cada petición | 30 minutos para la mayoría de aplicaciones | | Absoluto | Cuenta desde el inicio de sesión | 24 horas (o menos para aplicaciones sensibles) |

ts
// Middleware de validación de sesiones
function validateSession(session: Session): boolean {
  const now = Date.now();

  // Tiempo de espera de inactividad: 30 minutos
  if (now - session.lastActivity > 30 * 60 * 1000) {
    return false; // Sesión caducada por inactividad
  }

  // Tiempo de espera absoluto: 24 horas
  if (now - session.createdAt > 24 * 60 * 60 * 1000) {
    return false; // Sesión caducada por tiempo absoluto
  }

  return true;
}

Cierre de sesión seguro

Una función de cierre de sesión debe invalidar la sesión en el lado del servidor, no solo eliminar la cookie en el lado del cliente.

ts
// Ruta de API de Next.js: cierre de sesión seguro
export async function POST(request: Request) {
  const cookieStore = await cookies();
  const sessionId = cookieStore.get('session_id')?.value;

  if (sessionId) {
    // Invalida la sesión en el lado del servidor
    await db.sessions.delete({ where: { id: sessionId } });
  }

  // Limpia la cookie
  cookieStore.set('session_id', '', {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    path: '/',
    maxAge: 0, // Caducidad inmediata
  });

  return Response.json({ success: true });
}

Escenarios de ataque del mundo real

Escenario de ataque 1: robo de tokens mediante XSS en una dependencia

Un paquete npm popular en tu node_modules tiene una vulnerabilidad de contaminación de prototipos (prototype pollution). Un atacante crea un payload que lee localStorage.getItem('jwt') y lo envía a su servidor.

Por qué funciona: el JWT está en localStorage, accesible para cualquier JavaScript de la página — incluidas dependencias comprometidas, extensiones de navegador y scripts inyectados.

La solución: mueve el token de acceso a memoria y el token de refresco a una cookie HTTP-only. Aunque un payload XSS se ejecute, no puede leer variables en memoria ni cookies HTTP-only.

Escenario de ataque 2: CSRF en una API con SameSite=None

Un sitio de redes sociales incrusta una etiqueta de imagen que apunta a https://yourapp.com/api/transfer-funds?amount=1000&to=attacker. El usuario tiene la sesión iniciada con una cookie de sesión configurada con SameSite=None.

Por qué funciona: el navegador envía cookies automáticamente en cualquier petición a tu dominio, incluidas las peticiones de imagen entre orígenes. Si tu API se basa únicamente en cookies para la autenticación, la petición del atacante queda autenticada.

La solución: usa SameSite=Lax, exige tokens CSRF y verifica las cabeceras Origin/Referer en los endpoints sensibles.

Escenario de ataque 3: fijación de sesión

Un atacante envía a la víctima un enlace a https://yourapp.com/login?session_id=known_value. Si la aplicación no rota la sesión tras el inicio de sesión, el atacante puede usar el ID de sesión conocido para hacerse pasar por la víctima después de que esta inicie sesión.

Por qué funciona: el ID de sesión lo elige el atacante y nunca cambia durante ni después del inicio de sesión.

La solución: rota siempre el ID de sesión en el inicio de sesión, el cierre de sesión y la escalada de privilegios.

Cuestiones específicas de Next.js

Server Components y autenticación

En el App Router de Next.js, los server components pueden acceder a las cookies directamente, pero no pueden usar hooks de React como useEffect o useState. Esto cambia la forma de manejar la autenticación:

tsx
// [V] Correcto: el server component lee la autenticación desde las cookies
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';

export default async function DashboardPage() {
  const cookieStore = await cookies();
  const sessionId = cookieStore.get('session_id')?.value;

  if (!sessionId) {
    redirect('/login');
  }

  const session = await db.sessions.findUnique({
    where: { id: sessionId },
    include: { user: true },
  });

  if (!session || !validateSession(session)) {
    redirect('/login');
  }

  return <div>Welcome, {session.user.name}</div>;
}

Autenticación basada en middleware

Para la protección a nivel de ruta, usa el middleware de Next.js:

ts
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const protectedPaths = ['/dashboard', '/account', '/api/protected'];
const publicPaths = ['/login', '/register', '/blog'];

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const sessionId = request.cookies.get('session_id')?.value;

  // Ruta protegida, sin sesión
  if (protectedPaths.some(p => pathname.startsWith(p)) && !sessionId) {
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('redirect', pathname);
    return NextResponse.redirect(loginUrl);
  }

  // Usuario autenticado en la página de inicio de sesión
  if (pathname.startsWith('/login') && sessionId) {
    return NextResponse.redirect(new URL('/dashboard', request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*', '/account/:path*', '/login', '/register', '/api/protected/:path*'],
};

Lista de verificación de auditoría para sistemas de autenticación en React

Usa esta lista en tu próxima auditoría de seguridad o revisión de código:

  • [ ] Los tokens de acceso nunca se almacenan en localStorage ni sessionStorage
  • [ ] Los tokens de refresco se almacenan en cookies HTTP-only, Secure y SameSite
  • [ ] Vida útil del token de acceso ≤ 15 minutos
  • [ ] Rotación del token de refresco implementada en cada uso
  • [ ] Mecanismo de revocación de tokens en su lugar (deny list para JWT)
  • [ ] ID de sesión rotado en el inicio y cierre de sesión
  • [ ] Tiempo de espera de inactividad configurado (≤ 30 minutos)
  • [ ] Tiempo de espera absoluto configurado (≤ 24 horas)
  • [ ] Protección CSRF en todos los endpoints que modifican estado
  • [ ] Las cookies usan los flags httpOnly, secure y sameSite
  • [ ] El cierre de sesión invalida la sesión en el lado del servidor
  • [ ] Aplicación de middleware para rutas protegidas
  • [ ] Los tokens de restablecimiento de contraseña son de un solo uso y con límite de tiempo
  • [ ] Limitación de velocidad en los endpoints de inicio de sesión (previene fuerza bruta)
  • [ ] Autenticación multifactor disponible para operaciones sensibles

Conclusión

La autenticación no es un problema de talla única. Ya elijas JWT o autenticación basada en sesiones, la seguridad de tu sistema depende de los detalles de implementación: dónde almacenas los tokens, cómo configuras las cookies, cómo gestionas el ciclo de vida de las sesiones y cómo te defiendes contra CSRF.

Los patrones de esta guía te dan una base concreta y probada en producción. Empieza con la lista de verificación de auditoría para identificar las carencias de tu implementación actual y adopta después los patrones que aborden tu perfil de riesgo específico.

Recuerda: las vulnerabilidades de autenticación no son teóricas. Son el punto de entrada más común para los atacantes reales, y son las más fáciles de prevenir con los patrones adecuados desde el principio. Un sistema de autenticación bien diseñado es la inversión de seguridad con mayor retorno (ROI) que puedes hacer.

Comparte este artículo:TwitterLinkedIn
JS

JS Security Audit

Auditorías dirigidas por un ingeniero de seguridad JavaScript senior con más de 10 años de experiencia.