Skip to content

How-to: Refresh-Token-Muster

FT-Referenz: FT281 (NENE2-FT/refreshlog) — Refresh-Token-Muster: kurzlebiger Access-Token (5 Min JWT) + langlebiger Refresh-Token (7 Tage), SHA-256-Hash-Speicherung, Token-Rotation bei Verwendung, Replay-Angriff-Erkennung (widerrufenes Token → alle widerrufen), Logout gibt immer 204 zurück, 15 Tests / 63 Assertions bestanden.

Dieses Handbuch zeigt, wie man das Refresh-Token-Muster implementiert — kurzlebige Access-Tokens für Sicherheit, Refresh-Tokens für Sitzungskontinuität.

Warum es wichtig ist

JWTs sind zustandslos. Einmal ausgestellt können sie nicht widerrufen werden, bis sie ablaufen. Ein 5-Minuten-TTL begrenzt die Exposition, wenn ein Token gestohlen wird. Refresh-Tokens verlängern Sitzungen ohne wiederholte Passwort-Eingabeaufforderungen, und können bei jeder Verwendung rotiert (widerrufen und neu ausgestellt) werden, um Diebstahl zu erkennen.

Schema

sql
CREATE TABLE IF NOT EXISTS refresh_tokens (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id    INTEGER NOT NULL REFERENCES users(id),
    token_hash TEXT    NOT NULL UNIQUE,
    expires_at TEXT    NOT NULL,
    revoked    INTEGER NOT NULL DEFAULT 0,
    created_at TEXT    NOT NULL
);

token_hash speichert SHA-256 des rohen Tokens — niemals den rohen Wert. revoked ist ein Soft-Delete-Flag (vs. hartes Löschen für Replay-Erkennung).

Endpunkte

MethodePfadAuthBeschreibung
POST/auth/loginKeineE-Mail + Passwort → Access-Token + Refresh-Token
POST/auth/refreshRefresh-Token im BodyRefresh-Token rotieren, neues Paar ausstellen
POST/auth/logoutRefresh-Token im BodyRefresh-Token widerrufen
GET/auth/meBearer Access-TokenAktuelle Benutzerinfo abrufen

Token-Lebensdauer

php
private const int ACCESS_TOKEN_TTL_SECONDS = 300; // 5 Minuten — kurz für Sicherheit
// Refresh-Tokens: 7 Tage (RefreshTokenRepository::TTL_DAYS)

Token-Paar ausstellen

php
private function issueTokenPair(User $user): array
{
    $now         = time();
    $accessToken = $this->issuer->issue([
        'jti'   => bin2hex(random_bytes(8)),  // eindeutige Token-ID
        'sub'   => $user->id,
        'email' => $user->email,
        'iat'   => $now,
        'exp'   => $now + self::ACCESS_TOKEN_TTL_SECONDS,
    ]);

    $refreshToken = $this->refreshTokens->issue($user->id);

    return [
        'access_token'  => $accessToken,
        'token_type'    => 'Bearer',
        'expires_in'    => self::ACCESS_TOKEN_TTL_SECONDS,
        'refresh_token' => $refreshToken,
    ];
}

Nur den Hash speichern

php
public function issue(int $userId): string
{
    $raw       = bin2hex(random_bytes(32));  // 64 Hex-Zeichen = 256 Bit Entropie
    $hash      = hash('sha256', $raw);
    // INSERT token_hash = $hash ...

    return $raw;  // ← roh an Client zurückgeben; niemals gespeichert
}

Wenn die DB verletzt wird, bekommen Angreifer Hashes — nutzlos ohne die rohen Tokens, die Clients halten.

Token-Rotation

php
// Bei erfolgreicher /auth/refresh:
$this->refreshTokens->revoke($stored->id);      // alt widerrufen
return $this->json->create($this->issueTokenPair($user));  // neues Paar ausstellen

Jede Aktualisierung rotiert den Token. Das alte Token wird sofort ungültig, sodass ein gestohlenes Refresh-Token nur einmal verwendet werden kann, bevor die Rotation es ungültig macht.

Replay-Angriff-Erkennung

php
$stored = $this->refreshTokens->findByRaw($body['refresh_token']);

if ($stored === null || !$stored->isValid()) {
    // Widerrufenes Token wird erneut verwendet → potenzieller Replay-Angriff
    if ($stored !== null && $stored->revoked) {
        $this->refreshTokens->revokeAllForUser($stored->userId);
    }
    return $this->problems->create($request, 'invalid-refresh-token', '...', 401);
}

Wenn ein Angreifer ein Refresh-Token stiehlt, es verwendet, und der legitime Client dann versucht es zu verwenden (jetzt widerrufen) — erkennt das System dies und widerruft alle Sitzungen für den Benutzer, was eine erneute Authentifizierung erzwingt.

Logout gibt immer 204 zurück

php
private function logout(ServerRequestInterface $request): ResponseInterface
{
    $stored = $this->refreshTokens->findByRaw($body['refresh_token']);

    if ($stored !== null && !$stored->revoked) {
        $this->refreshTokens->revoke($stored->id);
    }

    // Immer 204 — niemals enthüllen ob das Token gültig war
    return $this->json->createEmpty(204);
}

Das Zurückgeben von 401 für ein bereits widerrufenes Token beim Logout würde einem Angreifer erlauben zu sondieren, ob er ausgeloggt wurde.

Sicherheitseigenschaften-Zusammenfassung

EigenschaftImplementierung
Access-Token-TTL5 Minuten (Diebstahl-Exposition minimieren)
Refresh-Token-TTL7 Tage (Sitzungskontinuität)
Token-SpeicherungNur SHA-256-Hash; roher Wert niemals gespeichert
Token-RotationAltes Token bei jeder erfolgreichen Aktualisierung widerrufen
Replay-ErkennungWiderrufenes Token erneut verwendet → alle Benutzersitzungen widerrufen
LogoutImmer 204 (niemals Token-Gültigkeit lecken)
jti-AnspruchEinzigartig pro Token (zukünftige Widerruf-Verfolgung)

Was man NICHT tun sollte

Anti-PatternRisiko
Rohen Refresh-Token in DB speichernDB-Einbruch exponiert alle aktiven Sitzungen
Hartes Löschen beim Widerrufen verwendenReplay-Angriffe können nicht erkannt werden (benötigt revoked = 1 um zu wissen, dass das Token existierte)
Langen Access-Token-TTL (Stunden/Tage)Gestohlenes Token bietet langfristigen Zugang
401 beim Logout mit ungültigem Token zurückgebenAngreifer kann sondieren ob sie noch eingeloggt sind
Kein jti im Access-TokenEinzelne Tokens können nicht für zukünftige Widerruf-Listen verfolgt werden
Einzelnes Token (nur Access, kein Refresh)Benutzer muss sich alle 5 Minuten erneut authentifizieren, oder gefährlich lange TTLs verwenden
MD5 oder SHA-1 für Token-HashSchwacher Hash; SHA-256 oder besser verwenden
Kein Ablauf bei Refresh-TokensRefresh-Tokens leben ewig; ein gestohlenes Token bietet unbegrenzten Zugang

Veröffentlicht unter der MIT-Lizenz.