Die meisten Anwendungen verwenden zwei Tokens. Ein kurzlebiges Access-Token (einige Minuten) begleitet jeden Request. Ein langlebiges Refresh-Token liefert ein neues Access-Token, ohne dass sich der Benutzer erneut anmelden muss. Schwierig ist nicht, einen Header anzuhängen, sondern der Moment, in dem das Access-Token abläuft, während schon fünf Requests unterwegs sind.
Die Seite baut Schritt für Schritt einen funktionalen Interceptor, der das Token anhängt, bei einem 401 einen einzigen Refresh ausführt, den alle wartenden Requests teilen, und sie danach erneut sendet. Du siehst außerdem, wo du die Tokens am besten aufbewahrst und was du tust, wenn der Refresh fehlschlägt.
Der gesamte Ablauf
Das Access-Token reist im Header Authorization: Bearer …. Antwortet der Server mit 401, fragt die Anwendung beim Refresh-Endpoint ein neues Token an. Das Refresh-Token wird nicht aus JavaScript gesendet: Es liegt in einem HttpOnly-Cookie, das der Browser automatisch mitschickt (withCredentials). Danach wird der ursprüngliche Request mit dem neuen Token wiederholt. Für den Benutzer bleibt alles unsichtbar.
browser API auth server
│ GET /orders (Bearer A1) │ │
│────────────────────────────▶│ A1 abgelaufen → 401 │
│◀────────────────────────────│ │
│ POST /auth/refresh (HttpOnly-Cookie mit Refresh-Token) │
│─────────────────────────────────────────────────────────────────▶│
│◀─────────────────────────────────────────── { accessToken: A2 } │
│ GET /orders (Bearer A2) │ │
│────────────────────────────▶│ 200 │Der Interceptor: ein Refresh gleichzeitig
Drei Details machen den Unterschied:
- Der Interceptor hängt das Token nur an die URLs deiner eigenen API an, damit du es nicht an Dritte sendest.
- Der Refresh-Request ist mit einem
HttpContextToken(SKIP_AUTH) markiert, damit sich der Interceptor nicht endlos selbst abfängt (siehe HttpClient & Interceptors). - Die Session hält den aktuellen Refresh in einem Feld. Der erste
401startet ihn, und alle anderen abonnieren dasselbe Observable (shareReplay(1)).finalizeleert das Feld am Ende.
Es gibt einen subtilen Fall: Ein Request kann einen 401 erhalten, nachdem ein anderer den Refresh bereits abgeschlossen hat. Dann weicht das aktuelle Token vom verwendeten ab, und der Request wird einfach ohne weiteren Refresh wiederholt.
import { HttpContextToken, HttpErrorResponse, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, switchMap, throwError } from 'rxjs';
/** Markierte Requests erhalten kein Token und lösen keinen Refresh aus (z. B. Login, Refresh). */
export const SKIP_AUTH = new HttpContextToken<boolean>(() => false);
const withToken = (req: HttpRequest<unknown>, token: string | null) =>
token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req;
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const session = inject(AuthSession);
if (req.context.get(SKIP_AUTH) || !req.url.startsWith('/api/')) return next(req); // nur deine API!
const used = session.token();
return next(withToken(req, used)).pipe(
catchError((err: unknown) => {
if (!(err instanceof HttpErrorResponse) || err.status !== 401) return throwError(() => err);
// ein anderer Request hat das Token schon erneuert, während dieser unterwegs war → wir wiederholen nur
if (session.token() && session.token() !== used) return next(withToken(req, session.token()));
return session.refresh().pipe(switchMap((fresh) => next(withToken(req, fresh))));
}),
);
};- Drücke 1, dann 2. Das Protokoll zeigt die genaue Reihenfolge der Requests.
Das Beispiel hat seinen eigenen HttpClient, erstellt mit createEnvironmentInjector([provideHttpClient(withInterceptors([…]))], …). Seine Interceptors (Auth + ein simulierter „Server“, als Letztes in der Kette) berühren den Rest der Anwendung nicht.
Die naive Variante und warum sie scheitert
Wenn jeder 401 seinen eigenen Refresh auslöst, wächst die Anzahl der Refreshes mit der Anzahl paralleler Requests. Viele Server rotieren das Refresh-Token (jeder Refresh macht das vorherige ungültig), sodass sich gleichzeitige Refreshes gegenseitig aufheben. Manche behandeln die Wiederverwendung eines alten Refresh-Tokens sogar als Angriff und beenden die ganze Session.
// ✗ Die naive Variante: Jeder 401 löst seinen eigenen Refresh aus
catchError((err) =>
err.status === 401
? http.post('/auth/refresh', null).pipe(switchMap((r) => next(withToken(req, r.accessToken))))
: throwError(() => err),
);
// 5 parallele Requests mit abgelaufenem Token → 5 Refreshes.
// Bei „Refresh Token Rotation“ macht jeder Refresh den vorherigen ungültig → manche Requests schlagen erneut fehl,
// und manche Server werten die Wiederverwendung als Angriff und beenden die ganze Session.Wo du die Tokens aufbewahrst
Jeder JavaScript-Code auf deiner Seite kann localStorage lesen, auch ein per XSS eingeschleustes Skript (siehe Sicherheit). Ein HttpOnly-Cookie kann nicht aus JavaScript gelesen werden, wird aber automatisch gesendet, daher brauchst du SameSite und XSRF-Schutz. Der übliche Kompromiss: das kurzlebige Access-Token im Speicher (ein Signal), das Refresh-Token in einem HttpOnly-Cookie. Am sichersten ist ein BFF (Backend-for-Frontend): Die OAuth-Tokens bleiben auf dem Server, und der Browser hat nur ein Session-Cookie.
| Wenn die Anwendung ein XSS hat | es lässt sich nicht direkt lesen, aber eingeschleuster Code kann Requests senden, solange die Seite offen ist |
|---|---|
| Beim Neuladen der Seite | geht verloren → Refresh-Token (Cookie) beim Start |
| CSRF | wird nicht automatisch gesendet → kein CSRF |
| Mehrere Tabs | jeder Tab macht einen eigenen Refresh |
empfohlen Gut für das kurzlebige Access-Token, kombiniert mit einem Refresh-Token in einem HttpOnly-Cookie.
Start, Guards und Tabs
Beim Start der Anwendung fehlt das Access-Token im Speicher. Ein provideAppInitializer versucht vor der ersten Route einen Refresh, damit ein angemeldeter Benutzer die Login-Seite nicht einmal kurz sieht. Der Authentifizierungszustand ist ein Signal, und der funktionale Guard liest es und leitet mit createUrlTree um (siehe Guards & Resolver). Der Logout muss an alle Tabs weitergegeben werden. Ein BroadcastChannel erledigt das in wenigen Zeilen (siehe auch Angular Elements).
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideAppInitializer(() => {
// beim Start: Liegt ein Refresh-Token im Cookie? Wir holen ein Access-Token vor der ersten Seite
const session = inject(AuthSession);
return firstValueFrom(session.refresh()).catch(() => undefined);
}),
],
};Wann ja, wann nein
- Dein Backend stellt die Tokens aus (eigenes JWT) und du brauchst nur Anhängen + Refresh.
- Du willst volle Kontrolle über die Request-Warteschlange und die Fehlerfälle.
- Du hast ein BFF, und der Interceptor fügt nur
withCredentialshinzu und behandelt401→ Login.
- OAuth 2 / OpenID Connect mit einem externen Provider (Entra ID, Auth0, Keycloak): Nutze eine zertifizierte OIDC-Bibliothek, die PKCE, Rotation und die Validierungen korrekt umsetzt. Den Flow selbst, Schritt für Schritt und mit echter Kryptografie, zeigt die Seite zu <a href="/oauth-pkce">OAuth 2.1 und PKCE</a>.
- Anwendungen mit hohen Sicherheitsanforderungen: Wähle ein BFF statt Tokens im Browser.
- Klassische Cookie-Sessions ohne Tokens: Du brauchst nur XSRF und die Behandlung von
401.
Häufige Stolperfallen
- Token wird überallhin gesendet: Ein Interceptor ohne URL-Filter hängt
Authorizationauch an Requests an CDNs oder Drittanbieter-APIs an. - Der Interceptor fängt den Refresh ab: Ein
401beim Refresh startet einen weiteren Refresh, und so weiter. Markiere den Request mit einemHttpContextToken. - Der Refresh wird nicht geteilt: N Requests bedeuten N Refreshes (siehe Beispiel).
- Retry bei jedem Fehler: Nur ein
401rechtfertigt einen Refresh. Ein403bedeutet „du darfst das nicht“, und ein Refresh ändert daran nichts. - Ein Logout, der nicht alles aufräumt: Stores,
shareReplay-Caches, Daten inlocalStorage. Der nächste Benutzer desselben Rechners sieht sie. - Das JWT im Frontend „der Sicherheit wegen“ dekodieren: Das ist nur informativ (du zeigst den Namen an). Jede Berechtigungsprüfung erfolgt auf dem Server.
Spickzettel
TOKENS kurzlebiges Access-Token (Minuten) im SPEICHER · langlebiges Refresh-Token in einem HttpOnly + Secure + SameSite Cookie
oder BFF: Der Browser hat nur ein Session-Cookie, die Tokens bleiben auf dem Server
INTERCEPTOR nur für die URLs deiner API · SKIP_AUTH (HttpContextToken) für Login/Refresh
401 ein Refresh gleichzeitig (shareReplay + finalize) · Retry mit dem neuen Token
hat sich das Token inzwischen geändert → nur Retry · Refresh schlägt fehl → Logout + Redirect
START provideAppInitializer: vor der ersten Route einen Refresh versuchen
GUARD CanActivateFn auf dem isLoggedIn-Signal → createUrlTree(['/login'], { returnUrl })
TABS BroadcastChannel / das storage-Event für synchronen Logout
NEIN Token in localStorage ohne CSP · ein eigener Refresh bei jedem 401 · ein Interceptor, der auch den Refresh abfängt