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
- Client sendet refresh_token (aus Cookie) an POST /token
- Server hashed Token, sucht in DB
- Wenn nicht gefunden → 401
- Wenn revoked_at gesetzt → ganze Familie revozieren, 401 - Reuse erkannt
- Wenn expires_at < now → 401
- Sonst: neuer Refresh-Token (gleiche family_id, parent_id = alter), alten als revoked markieren
- 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
| Problem | Lösung |
|---|---|
| User öffnet App in 2 Browser-Tabs gleichzeitig, beide refreshen parallel | Optimistic 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 Tagen | Sliding-Window: jeder Refresh setzt expires_at neu, max 30 Tage. Inaktive User werden nach 30 Tagen ausgeloggt |
| User loggt sich auf neuem Device ein | Neue 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.