jwtdecoder.de

Case-Study · API-Backend mit User-Login (Node.js + PostgreSQL)

Case-Study: Refresh-Token-Rotation richtig implementiert

Refresh-Token wird bei jedem Refresh ersetzt. Wird ein alter Refresh-Token nochmal verwendet, war er kompromittiert - Familie wird sofort revoziert.

Eckdaten

Access-Token-Lifetime

10 Minuten

Refresh-Token-Lifetime

30 Tage (sliding)

Rotation

Bei jedem Refresh

Reuse-Detection

Aktiv

Schritte / Maßnahmen

  • Access-Token (10 min, HS256) im Memory-Store des Clients
  • Refresh-Token (30d, opake UUID) im httpOnly-Cookie
  • Refresh-Token-Familie in DB mit parent_id-Verkettung
  • Bei /token: alten Refresh-Token revozieren, neuen ausstellen
  • Wird revozierter Token nochmal genutzt → ganze Familie blacklisten

Ein API-Backend mit ~50k aktiven Usern. Auth-Stack: Access-Token (JWT, HS256, 10 min) für API-Calls, Refresh-Token (opake UUID, 30 Tage) im httpOnly-Cookie für Token-Renewal. Refresh-Token-Rotation mit Reuse-Detection nach OAuth-2.0-Best-Practice (draft-ietf-oauth-security-topics).

Warum opaker Refresh-Token statt JWT?

Access-Token ist JWT (HS256), weil API-Server es schnell stateless verifizieren können. Refresh-Token ist opak (zufälliger UUID), weil es ohnehin gegen die DB geprüft werden muss - Stateless-Vorteil entfällt. Plus: opake Refresh-Tokens können einfacher revoziert werden (DB-Lookup).

Datenbank-Schema

CREATE TABLE refresh_tokens (
  id           UUID PRIMARY KEY,
  user_id      UUID NOT NULL,
  family_id    UUID NOT NULL,
  parent_id    UUID REFERENCES refresh_tokens(id),
  token_hash   TEXT NOT NULL,  -- bcrypt-hash des Tokens
  issued_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  expires_at   TIMESTAMPTZ NOT NULL,
  revoked_at   TIMESTAMPTZ,
  revoked_reason TEXT
);
CREATE INDEX ON refresh_tokens(user_id, revoked_at) WHERE revoked_at IS NULL;
CREATE INDEX ON refresh_tokens(family_id);

family_id ist konstant über die ganze Token-Kette (Login → Refresh → Refresh → ...). parent_id verkettet die Tokens chronologisch.

/token-Endpoint Logik

  1. Client sendet refresh_token (aus Cookie) an POST /token
  2. Server hashed Token, sucht in DB
  3. Wenn nicht gefunden → 401
  4. Wenn revoked_at gesetzt → ganze Familie revozieren, 401 - Reuse erkannt
  5. Wenn expires_at < now → 401
  6. Sonst: neuer Refresh-Token (gleiche family_id, parent_id = alter), alten als revoked markieren
  7. Neues Access-Token ausstellen, beide returnen

Reuse-Detection Beispiel-Szenario

Angreifer stiehlt einen Refresh-Token (XSS, gestohlenes Gerät). Verwendet ihn:

  • T+0: Angreifer: /token mit gestohlenem Refresh-Token RT1 → bekommt RT2 + AT2. Server markiert RT1 als revoked.
  • T+5min: Echter User loggt sich ein, sein Client hat noch RT1 (nicht von Angreifer ersetzt). Client schickt RT1 → Server sieht: RT1 ist revoked → Reuse erkannt → ganze family revoziert (auch RT2 des Angreifers).
  • T+5min+: Beide (Angreifer und User) sind ausgeloggt, müssen sich neu authentifizieren. User-Auth ist sicher (Passwort + 2FA), Angreifer-Auth schlägt fehl.

Edge-Cases die wir aufgelöst haben

ProblemLösung
User öffnet App in 2 Browser-Tabs gleichzeitig, beide refreshen parallelOptimistic Locking: erstes Refresh gewinnt, zweites bekommt den schon ausgegebenen Token (innerhalb 5s Toleranz)
Mobile-App im Hintergrund, Token läuft ab, User öffnet App nach TagenSliding-Window: jeder Refresh setzt expires_at neu, max 30 Tage. Inaktive User werden nach 30 Tagen ausgeloggt
User loggt sich auf neuem Device einNeue Familie. Alte Familien bleiben aktiv (Multi-Device-Support)
User klickt "Auf allen Geräten ausloggen"Alle nicht-revozierten Tokens des Users in einem Query revozieren

Was wir gelernt haben

Reuse-Detection war zweimal hilfreich in den letzten 18 Monaten - beide Male durch User-Reports ("ich war plötzlich auf allen Geräten ausgeloggt"). Beide hatten ihr Passwort geleakt (Credential-Stuffing), Angreifer hatten kurz Sessions, die Familie wurde durch ihren eigenen Re-Login revoziert. Ohne Rotation hätten die Angreifer Wochen-langen Zugang gehabt.