Zum Inhalt springen

OAuth-2.0-Token-Sicherheit: Speicherung, Rotation und Widerruf

Sichere Verwaltung des OAuth-2.0-Token-Lebenszyklus: Speicherstrategien, automatische Rotation, Widerruf-Propagierung und Replay-Schutz.

5 Min. Lesezeit
Diagramm des Token-Lebenszyklus, das Ausstellung, Speicherung, Rotation über Refresh-Tokens und Widerrufsabläufe mit Sicherheitsgrenzen auf jeder Stufe zeigt

Einen OAuth-2.0-Access-Token zu erhalten ist der einfache Teil. Ihn während seines gesamten Lebenszyklus abzusichern – Speicherung, Übertragung, Rotation und Widerruf – ist der Punkt, an dem die meisten Anwendungen scheitern. Ein gestohlener Token ist eine gestohlene Identität. Jede Entscheidung darüber, wo Tokens gespeichert werden, wie lange sie gültig sind und wie sie erneuert werden, wirkt sich direkt auf den Schadensradius eines Sicherheitsvorfalls aus.

Die hier beschriebenen Muster gelten unabhängig davon, ob du OAuth-Tokens eines Anbieters nutzt oder einen eigenen Autorisierungsserver baust.

Token-Speicherung: Wo Tokens liegen, ist entscheidend

Der Speicherort bestimmt, welche Angriffsvektoren den Token stehlen können. Jede Option bringt eigene Kompromisse mit sich.

tstypescript
// ❌ Storing tokens in localStorage — vulnerable to XSS
localStorage.setItem("access_token", token);
// Any script on the page can read this, including
// injected scripts from XSS vulnerabilities
tstypescript
// ✅ HTTP-only secure cookies for web applications
import { Response } from "express";
 
interface TokenCookieOptions {
  accessTokenMaxAge: number;
  refreshTokenMaxAge: number;
  domain: string;
  sameSite: "strict" | "lax" | "none";
}
 
function setTokenCookies(
  res: Response,
  accessToken: string,
  refreshToken: string,
  options: TokenCookieOptions
): void {
  // Access token: short-lived, HTTP-only
  res.cookie("access_token", accessToken, {
    httpOnly: true,
    secure: true,
    sameSite: options.sameSite,
    domain: options.domain,
    maxAge: options.accessTokenMaxAge,
    path: "/api",
  });
 
  // Refresh token: longer-lived, restricted path
  res.cookie("refresh_token", refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: "strict",
    domain: options.domain,
    maxAge: options.refreshTokenMaxAge,
    path: "/api/auth/refresh",
  });
}
 
// For SPAs that can't use cookies (mobile, cross-origin):
// Use in-memory storage with a service worker
class SecureTokenStore {
  private accessToken: string | null = null;
  private refreshToken: string | null = null;
 
  setTokens(access: string, refresh: string): void {
    this.accessToken = access;
    this.refreshToken = refresh;
    // Do NOT persist to localStorage or sessionStorage
  }
 
  getAccessToken(): string | null {
    return this.accessToken;
  }
 
  getRefreshToken(): string | null {
    return this.refreshToken;
  }
 
  clear(): void {
    this.accessToken = null;
    this.refreshToken = null;
  }
}

HTTP-only-Cookies sind für JavaScript unsichtbar, wodurch Token-Diebstahl per XSS ausgeschlossen wird. Die Einschränkung über path sorgt dafür, dass der Refresh-Token nur an den Refresh-Endpunkt gesendet wird, nicht bei jedem API-Aufruf.

Token-Rotation mit Refresh-Tokens

Kurze Gültigkeitsdauern des Access-Tokens begrenzen das Zeitfenster, in dem ein gestohlener Token ausgenutzt werden kann. Mit Refresh-Tokens lassen sich neue Access-Tokens ausstellen, ohne dass eine erneute Anmeldung nötig ist.

tstypescript
interface TokenPair {
  accessToken: string;
  refreshToken: string;
  expiresIn: number;
}
 
interface RefreshTokenRecord {
  token: string;
  userId: string;
  family: string; // Token family for rotation detection
  used: boolean;
  createdAt: Date;
  expiresAt: Date;
}
 
class TokenRotationService {
  private refreshTokens: Map<string, RefreshTokenRecord> = new Map();
 
  async issueTokenPair(userId: string): Promise<TokenPair> {
    const family = this.generateFamily();
    return this.createPair(userId, family);
  }
 
  async refresh(refreshToken: string): Promise<TokenPair> {
    const record = this.refreshTokens.get(refreshToken);
 
    if (!record) {
      throw new SecurityError("Invalid refresh token");
    }
 
    if (record.expiresAt < new Date()) {
      this.refreshTokens.delete(refreshToken);
      throw new SecurityError("Refresh token expired");
    }
 
    // CRITICAL: Detect token reuse
    if (record.used) {
      // This refresh token was already used — possible theft
      // Revoke the entire token family
      await this.revokeFamily(record.family);
      throw new SecurityError(
        "Refresh token reuse detected — all sessions revoked"
      );
    }
 
    // Mark current token as used
    record.used = true;
 
    // Issue new pair with same family
    return this.createPair(record.userId, record.family);
  }
 
  private createPair(
    userId: string,
    family: string
  ): TokenPair {
    const accessToken = this.generateAccessToken(userId);
    const refreshToken = this.generateRefreshToken();
 
    this.refreshTokens.set(refreshToken, {
      token: refreshToken,
      userId,
      family,
      used: false,
      createdAt: new Date(),
      expiresAt: new Date(
        Date.now() + 7 * 24 * 60 * 60 * 1000
      ),
    });
 
    return {
      accessToken,
      refreshToken,
      expiresIn: 900, // 15 minutes
    };
  }
 
  private async revokeFamily(family: string): Promise<void> {
    for (const [token, record] of this.refreshTokens) {
      if (record.family === family) {
        this.refreshTokens.delete(token);
      }
    }
  }
 
  private generateFamily(): string {
    return crypto.randomUUID();
  }
 
  private generateAccessToken(userId: string): string {
    // Sign with short expiry
    return `at_${userId}_${Date.now()}`;
  }
 
  private generateRefreshToken(): string {
    return `rt_${crypto.randomUUID()}`;
  }
}
 
class SecurityError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "SecurityError";
  }
}

Die Nachverfolgung der Token-Familie ist der zentrale Sicherheitsmechanismus. Wird ein Refresh-Token zweimal verwendet – etwa weil ein Angreifer den Token kopiert hat und sowohl er als auch der rechtmäßige Nutzer ihn verwenden –, wird die gesamte Familie widerrufen. Der echte Nutzer muss sich dann erneut anmelden, der Angreifer wird jedoch ausgesperrt.

Automatische Token-Erneuerung im Client

Der Client muss abgelaufene Tokens transparent erneuern, ohne die Nutzererfahrung zu unterbrechen.

tstypescript
class AuthenticatedClient {
  private tokenStore: SecureTokenStore;
  private refreshPromise: Promise<void> | null = null;
 
  constructor(
    private baseUrl: string,
    tokenStore: SecureTokenStore
  ) {
    this.tokenStore = tokenStore;
  }
 
  async request(
    path: string,
    options: RequestInit = {}
  ): Promise<Response> {
    const token = this.tokenStore.getAccessToken();
 
    const response = await fetch(`${this.baseUrl}${path}`, {
      ...options,
      headers: {
        ...options.headers,
        Authorization: token ? `Bearer ${token}` : "",
      },
    });
 
    // If 401, try refreshing and retrying once
    if (response.status === 401) {
      await this.ensureRefreshed();
      const newToken = this.tokenStore.getAccessToken();
 
      if (!newToken) {
        // Refresh failed — redirect to login
        this.handleSessionExpired();
        throw new Error("Session expired");
      }
 
      return fetch(`${this.baseUrl}${path}`, {
        ...options,
        headers: {
          ...options.headers,
          Authorization: `Bearer ${newToken}`,
        },
      });
    }
 
    return response;
  }
 
  private async ensureRefreshed(): Promise<void> {
    // Deduplicate concurrent refresh attempts
    if (!this.refreshPromise) {
      this.refreshPromise = this.doRefresh();
      try {
        await this.refreshPromise;
      } finally {
        this.refreshPromise = null;
      }
    } else {
      await this.refreshPromise;
    }
  }
 
  private async doRefresh(): Promise<void> {
    const refreshToken = this.tokenStore.getRefreshToken();
    if (!refreshToken) return;
 
    const response = await fetch(
      `${this.baseUrl}/api/auth/refresh`,
      {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ refreshToken }),
      }
    );
 
    if (response.ok) {
      const data = await response.json();
      this.tokenStore.setTokens(
        data.accessToken,
        data.refreshToken
      );
    } else {
      this.tokenStore.clear();
    }
  }
 
  private handleSessionExpired(): void {
    this.tokenStore.clear();
    // Redirect to login
  }
}

Die Deduplizierung über refreshPromise ist entscheidend. Ohne sie würden zehn gleichzeitige API-Aufrufe, die auf einen 401-Status stoßen, zehn Refresh-Anfragen auslösen und dadurch Tokens schneller invalidieren, als sie genutzt werden können.

Weitergabe des Token-Widerrufs

Wenn sich ein Nutzer abmeldet oder ein Sicherheitsereignis den Widerruf eines Tokens auslöst, müssen aktive Tokens in allen Diensten, die sie akzeptieren, ungültig gemacht werden.

tstypescript
interface RevocationEntry {
  tokenId: string;
  revokedAt: Date;
  reason: string;
  expiresAt: Date; // Clean up after token would have expired
}
 
class TokenRevocationList {
  private revoked: Map<string, RevocationEntry> = new Map();
 
  revoke(tokenId: string, reason: string, ttl: number): void {
    this.revoked.set(tokenId, {
      tokenId,
      revokedAt: new Date(),
      reason,
      expiresAt: new Date(Date.now() + ttl * 1000),
    });
  }
 
  isRevoked(tokenId: string): boolean {
    return this.revoked.has(tokenId);
  }
 
  cleanup(): void {
    const now = new Date();
    for (const [id, entry] of this.revoked) {
      if (entry.expiresAt < now) {
        this.revoked.delete(id);
      }
    }
  }
}
 
// Middleware that checks revocation before processing
function revocationCheckMiddleware(
  revocationList: TokenRevocationList
) {
  return (req: Request, res: Response, next: NextFunction) => {
    const token = extractToken(req);
    if (!token) {
      return res.status(401).json({ error: "No token" });
    }
 
    const decoded = decodeToken(token);
    if (revocationList.isRevoked(decoded.jti)) {
      return res.status(401).json({ error: "Token revoked" });
    }
 
    next();
  };
}
 
function extractToken(req: any): string | null {
  const auth = req.headers.authorization;
  if (auth?.startsWith("Bearer ")) {
    return auth.slice(7);
  }
  return req.cookies?.access_token ?? null;
}
 
function decodeToken(token: string): { jti: string; sub: string } {
  // Decode JWT payload (verification happens elsewhere)
  const payload = token.split(".")[1];
  return JSON.parse(Buffer.from(payload, "base64url").toString());
}

Wichtigste Erkenntnisse

Der Speicherort des Tokens bestimmt deine Angriffsfläche: Verwende HTTP-only-Secure-Cookies für Webanwendungen, um Token-Diebstahl per XSS auszuschließen, und Speicherung im Arbeitsspeicher für SPAs, bei denen Cookies keine Option sind. Halte Access-Tokens kurzlebig (15 Minuten oder weniger) und nutze Refresh-Token-Rotation, um neue Paare auszustellen und so das Zeitfenster für einen Missbrauch bei gestohlenen Tokens zu verkleinern. Implementiere die Nachverfolgung von Token-Familien, um die Wiederverwendung von Refresh-Tokens zu erkennen: Werden sowohl ein gestohlener als auch der rechtmäßige Token verwendet, widerrufe die gesamte Familie, um den Angreifer auszusperren. Dedupliziere gleichzeitige Token-Refresh-Anfragen auf dem Client, um Race Conditions zu vermeiden, bei denen mehrere 401-Antworten mehrere Refresh-Aufrufe auslösen. Führe eine Widerrufsliste für Tokens, um sie bei Abmeldung oder Sicherheitsereignissen sofort ungültig zu machen, und gib den Widerruf an alle Dienste weiter, die Tokens validieren. Der sichere Token-Lebenszyklus beruht nicht auf einem einzelnen Mechanismus – er ergibt sich aus der Kombination von kurzen Gültigkeitsdauern, Rotation, Erkennung von Wiederverwendung und Widerruf, die den Schaden begrenzt, wenn (nicht falls) ein Token kompromittiert wird.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX