Bei der Anmeldung mit Google, Microsoft, Keycloak oder Auth0 sieht deine App das Passwort nie. Sie schickt den Nutzer zum Identitätsanbieter, der ihn mit einem einmaligen Code zurückschickt; die App tauscht diesen Code gegen Tokens. OAuth 2.1 — die aufgeräumte Version von OAuth 2.0 — behält für Apps im Browser einen einzigen Flow: den Authorization Code mit PKCE.
PKCE (Proof Key for Code Exchange, ausgesprochen „pixy“) löst ein präzises Problem. Eine Single-Page-App ist ein öffentlicher Client: Sie hat kein Geheimnis, das sie verstecken könnte, denn alles, was sie ausliefert, kann gelesen werden. Also braucht sie einen anderen Weg, zu beweisen, dass derjenige, der den Code einlöst, derselbe ist, der ihn angefordert hat. Die Demos auf dieser Seite verwenden echte Kryptografie (Web Crypto), keine Nachahmung.
response_type=token, Tokens im URL-Fragment) ist aus OAuth 2.1 verschwunden: Tokens in URLs landen im Browserverlauf, in Logs und in Referer-Headern. Der Umstieg auf den Code Flow mit PKCE ist meist eine Konfigurationsänderung in deiner Bibliothek und beim Anbieter.Der Flow in sieben Schritten
Die App bereitet ein paar Zufallswerte vor, schickt den Browser zum Anbieter, bekommt einen Code zurück und tauscht ihn — zusammen mit einem Beweis — gegen Tokens. Erst der letzte Schritt betrifft deine API. Geh ihn in der Demo durch; hake „Angreifer“ an, um zu sehen, was die Prüfung von state verhindert.
app (browser) Identitätsanbieter API
│ 1. verifier, challenge = S256(verifier), state, nonce │
│ 2. GET /authorize?…&code_challenge&state&nonce ─▶│ │
│ 3. Login, MFA, Einwilligung │
│◀─ 4. 302 /callback?code=…&state=… (state prüfen!) │
│ 5. POST /token code + code_verifier ────────────▶│ S256(verifier) == challenge?
│◀─ 6. access_token, id_token, refresh_token ───────│ │
│ 7. GET /orders Authorization: Bearer <access_token> ─────────────────────▶│PKCE: beweisen, dass du gefragt hast
Vor dem Redirect erzeugt die App einen zufälligen code_verifier und schickt nur dessen Hash, die code_challenge, an /authorize. Der Anbieter merkt ihn sich neben dem Code. Wenn die App den Code bei /token einlöst, schickt sie den Verifier selbst; der Anbieter hasht ihn und vergleicht. Wer den Code abfängt — eine bösartige App mit demselben Redirect, ein Log, eine Browser-Erweiterung —, hat den Verifier nicht, und der Code ist für ihn wertlos.
/** base64url ohne Padding (RFC 4648 §5), wie PKCE es verlangt */
function base64url(bytes: Uint8Array): string {
let binary = '';
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/** 32 Zufallsbytes → 43 Zeichen (erlaubt sind mindestens 43, höchstens 128) */
export function createVerifier(): string {
return base64url(crypto.getRandomValues(new Uint8Array(32)));
}
/** code_challenge = BASE64URL(SHA-256(code_verifier)), Methode S256 */
export async function challengeFor(verifier: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
return base64url(new Uint8Array(digest));
}
// RFC 7636, Anhang B:
// challengeFor('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk')
// → 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'- 1Die App erzeugt einen Verifier
0 Zeichen · erlaubt sind 43 bis 128
code_challenge… - 2Nur die Challenge geht an /authorize
Der Anbieter hat noch nichts erhalten.
- 3Den Code bei /token einlösen
Verwende S256, nie plain (bei plain ist die Challenge der Verifier, wer den Redirect sieht, hat also alles). Der Verifier bleibt in sessionStorage — nur dieser Tab — und wird gelöscht, sobald der Code getauscht ist.
state, nonce und redirect_uri
state— ein Zufallswert, vor dem Redirect gespeichert und in/callbackgeprüft. Ohne ihn kann ein Angreifer das Opfer mit dem Code des Angreifers zu deinem Callback schicken, und das Opfer ist am Ende im Konto des Angreifers angemeldet (Login-CSRF). PKCE blockiert das auch, aber Anbieter verlangenstatetrotzdem, und du brauchst ihn ohnehin, um deinen eigenen Kontext mitzunehmen.nonce— ein Zufallswert, an/authorizegeschickt, der im ID-Token zurückkommen muss. Er beweist, dass das ID-Token für diesen Login ausgestellt und nicht von einem früheren kopiert wurde.redirect_uri— beim Anbieter exakt registriert, ohne Wildcards. Und wohin es nach dem Login geht (returnTo), speichert deine App vor dem Redirect; nimm es nie aus einem?returnUrl=in der Adresszeile, sonst wird deine Login-Seite zum Open Redirect.
import { challengeFor, createVerifier } from './pkce';
const AUTHORIZE = 'https://id.example.com/authorize';
export async function login(returnTo = '/'): Promise<void> {
const verifier = createVerifier();
const state = createVerifier(); // zufällig, nur einmal verwendbar
const nonce = createVerifier();
// sessionStorage: nur dieser Tab, weg, wenn der Tab geschlossen wird
sessionStorage.setItem('oauth', JSON.stringify({ verifier, state, nonce, returnTo }));
const url = new URL(AUTHORIZE);
url.search = new URLSearchParams({
response_type: 'code',
client_id: 'atlas-spa',
redirect_uri: `${location.origin}/callback`, // GENAU so registriert, keine Wildcards
scope: 'openid profile email',
code_challenge: await challengeFor(verifier),
code_challenge_method: 'S256',
state,
nonce,
}).toString();
location.assign(url);
}Drei Tokens, drei Aufgaben
Die Antwort von /token enthält bis zu drei Tokens, und sie zu verwechseln ist der häufigste Fehler. Das ID-Token sagt der App, wer sich angemeldet hat. Das Access-Token sagt der API, was der Aufrufer darf. Das Refresh-Token holt neue Access-Tokens ohne neuen Login. Ein JWT im Browser zu dekodieren ist für die Anzeige nützlich, aber keine Validierung: Die Signatur wird mit den öffentlichen Schlüsseln des Anbieters (JWKS) geprüft, und das ist Arbeit für eine Bibliothek.
| ID token | Access token | Refresh token | |
|---|---|---|---|
| Gelesen von | deiner App | deiner API | dem Anbieter |
| Gesendet an | nirgendwohin — es bleibt in der App | nur den Origin deiner API | nur /token des Anbieters |
| Lebt | Minuten | 5–60 Minuten | Stunden bis Tage, bei jeder Nutzung rotiert |
Sicherer: ein BFF behält die Tokens
Nach Schritt 6 liegen die Tokens im Browser — genau dort, wo ein XSS sie erreichen kann. Das IETF-Dokument zu OAuth für browserbasierte Apps beschreibt eine stärkere Option: ein Backend for Frontend. Ein kleiner Server auf deiner eigenen Site führt den Code-Tausch durch, hält die Tokens serverseitig und gibt dem Browser nur ein HttpOnly-Session-Cookie, das JavaScript nicht lesen kann.
browser (SPA) BFF (dein Server, gleiche Site) Identitätsanbieter
│ GET /auth/login ──────────▶│ PKCE + state + nonce, auf dem Server ──────▶│ /authorize
│◀─ 302 zum Anbieter ────────│ │
│ …Login beim Anbieter, Redirect zurück zum BFF… │
│ │ code + verifier ──────────────────────────▶│ /token
│ │◀── access, id, refresh (bleiben HIER) ──────│
│◀─ Set-Cookie: session=…; HttpOnly; Secure; SameSite=Strict │
│ GET /api/orders (cookie) ─▶│ setzt Authorization: Bearer … ──▶ API │Die SPA ruft /api auf ihrem eigenen Origin auf; das BFF fügt das Access-Token hinzu und kümmert sich um den Refresh. Ein XSS kann weiterhin Anfragen stellen, solange die Seite offen ist, aber keine Tokens stehlen, um sie anderswo zu nutzen. Der Preis: eine Komponente mehr im Betrieb und CSRF-Schutz für das Cookie (SameSite, ein Anti-CSRF-Header). Für Tokens im Browser steht der Interceptor mit Warteschlange und Refresh auf der Seite zu Tokens und Refresh.
In Angular: eine Bibliothek, ein Guard und ein Interceptor
Implementiere das Protokoll nicht selbst. Zertifizierte Bibliotheken wie angular-auth-oidc-client oder angular-oauth2-oidc übernehmen Discovery des Anbieters, PKCE, Validierung des ID-Tokens samt Signatur, Refresh und Logout. Der Code auf dieser Seite zeigt, was sie tun, damit du sie mit offenen Augen konfigurieren und debuggen kannst. Dein Teil:
- Konfiguration: Authority, Client-ID,
redirect_uri, Scopes und der Code Flow mit PKCE — meist der Standard. - Ein Guard auf den privaten Routen, der den Login startet und sich merkt, wohin es zurückgeht. Siehe Guards & Resolver.
- Ein Interceptor, der das Access-Token nur für den Origin deiner API hinzufügt. Ein Token, das an jede URL geht, landet bei Dritten; siehe Sicherheit für den Rest des Bildes.
Wann ja, wann nein
- Login über einen externen Identitätsanbieter: Firmen-SSO, Google, Microsoft, Apple.
- Mehrere Apps, die denselben Login und dieselben Nutzer teilen.
- MFA, Passkeys und Kontowiederherstellung, die du lieber Spezialisten überlässt.
- Eine einzelne App mit eigenen Nutzern und eigenem Backend: Ein klassisches Session-Cookie kann alles sein, was du brauchst. Tokens und Refresh haben eine eigene Seite.
- Server-zu-Server-Aufrufe: Das ist der Client-Credentials-Flow, auf dem Server, nie im Browser.
- Das Protokoll in Produktion selbst schreiben: Nimm eine zertifizierte Bibliothek.
Häufige Stolperfallen
- Der Implicit Flow oder Tokens in der URL. Sie landen im Verlauf, in Logs und im
Referer-Header. statenicht prüfen. Der Callback akzeptiert einen Code, den deine App nie angefordert hat.- Das ID-Token an die API geschickt. Es ist für die App; die API nimmt Access-Tokens mit der richtigen Audience.
- Dekodieren statt validieren. Ohne Signaturprüfung kann jeder ein JWT mit beliebigen Claims schreiben.
- Der Verifier in
localStorage, nach Gebrauch behalten. Er gehört insessionStorageund stirbt mit dem Tausch. ?returnUrl=aus der Adresszeile. Deine Login-Seite wird zum Open Redirect für Phishing.
Spickzettel
Browser-Apps (OAuth 2.1) Authorization Code + PKCE, sonst nichts (kein Implicit Flow)
code_verifier 43–128 Zufallszeichen, in sessionStorage, nach Gebrauch gelöscht
code_challenge BASE64URL(SHA-256(verifier)), Methode S256 (nie "plain")
state zufällig, auf /callback geprüft: stoppt Login-CSRF
nonce zufällig, muss im ID-Token stehen: stoppt wiederverwendete Tokens
redirect_uri exakt registriert, keine Wildcards; returnTo VOR dem Redirect gespeichert
ID-Token wer sich angemeldet hat (für die App); iss, aud, exp, nonce, Signatur prüfen
Access-Token was du aufrufen darfst (für die API); nur an den Origin deiner API
Refresh-Token bei jeder Nutzung rotiert oder gar nicht im Browser
am sichersten ein BFF: Tokens auf dem Server, ein HttpOnly-Cookie im Browser
Logout lokale Session + end_session_endpoint des Anbieters
nicht selbst schreiben zertifizierte Bibliotheken: angular-auth-oidc-client, angular-oauth2-oidc