· Architektur · 5 minuten Lesezeit
access_token, id_token und der Userinfo-Endpoint
OAuth 2.0 und OIDC liefern bei einem Login zwei Tokens, und fast jede Implementierung liest die E-Mail-Adresse aus dem falschen. Warum access_token und id_token grundlegend verschiedene Zwecke haben und wieso der Userinfo-Endpoint die zuverlässigere Quelle für Nutzerprofildaten ist.

Das Problem
Nach einem erfolgreichen Login zeigte die Chrome Extension ein grünes Lämpchen. Eingeloggt, aber keine E-Mail-Adresse. Das React-Frontend zeigte statt des Nutzernamens den Fallback-Wert "Dev-Nutzer".
Der Code sah korrekt aus. Die Scopes openid profile email wurden angefragt. Das Token wurde empfangen. Aber das E-Mail-Feld war leer.
Die Ursache: Der Code las email aus dem Access Token. Zitadel legt die E-Mail in den ID Token, aber nur dann, wenn die E-Mail-Adresse verifiziert ist.
Das ist kein Zitadel-Bug. Das ist das OIDC-Design.
Zwei Tokens, zwei Zwecke
OAuth 2.0 und OIDC liefern bei einem erfolgreichen Login (mindestens) zwei Tokens:
access_token | id_token | |
|---|---|---|
| Frage | Was darf dieser Aufrufer? | Wer hat sich gerade eingeloggt? |
| Für wen | Dein Backend-Server | Deine Frontend-Applikation |
| Inhalt | Berechtigungen, Scopes, sub (User-ID) | Nutzerprofil: email, name, preferred_username |
| Standard | OAuth 2.0 | OpenID Connect (OIDC), aufgebaut auf OAuth 2.0 |
| Du sendest es… | Im Authorization: Bearer-Header an dein Backend | Gar nicht, du liest es lokal aus |
Der access_token ist ein Berechtigungsnachweis für Ressourcen. Das Backend validiert ihn bei jedem API-Aufruf gegen den JWKS-Endpoint des Identity Providers. Er beantwortet die Frage: “Hat dieser Aufrufer das Recht, diese Ressource abzurufen?”
Der id_token ist ein Identitätsnachweis. Er beantwortet die Frage: “Wer hat sich gerade eingeloggt?” Er gehört dem Frontend, nicht dem Backend.
Warum OIDC die Profildaten trennt
Ein JWT reist durch viele Stationen: URL-Fragment beim Redirect, Browser-History, Server-Logs, Referrer-Header, Load-Balancer-Logs. Je mehr persönliche Daten in einem JWT stehen, desto größer das Risiko bei einem Leak.
Das OIDC-Design löst das so:
- Der
id_tokenenthält nur das Minimum für den Login (sub,iss,aud,exp) - Erweiterte Profildaten (
email,name,phone_number) kommen vom Userinfo-Endpoint - Der Userinfo-Endpoint ist ein direkter HTTPS-Request, er erscheint nie in Logs oder URLs
Das ist kein theoretisches Detail. Für DSGVO-konforme Systeme ist die Unterscheidung relevant: Persönliche Daten sollen so wenig wie möglich im Transit erscheinen.
Der Userinfo-Endpoint
Jeder OIDC-konforme Identity Provider stellt einen Userinfo-Endpoint bereit. Bei Zitadel:
GET /oidc/v1/userinfo
Authorization: Bearer <access_token>Der access_token dient hier als Autorisierung. Er schützt keine externe Ressource, sondern gibt das Profil des eigenen Nutzers zurück. Der Identity Provider prüft den Token und gibt nur Daten zurück, die dem Token-Inhaber gehören. Du kannst mit deinem Token nicht das Profil eines anderen Nutzers abrufen.
Die Antwort enthält alle Claims, die bei den angeforderten Scopes (profile, email) definiert sind:
{
"sub": "374907089107025923",
"email": "user@example.com",
"email_verified": true,
"preferred_username": "user@example.com",
"name": "Max Mustermann",
"given_name": "Max",
"family_name": "Mustermann"
}Wie das in der Praxis aussieht
React Frontend mit react-oidc-context
react-oidc-context dekodiert den id_token standardmäßig lokal und macht die Claims über auth.user.profile verfügbar. Das reicht nicht für Zitadel, weil email im id_token fehlen kann.
Die Lösung: loadUserInfo: true in der OIDC-Konfiguration:
// frontend/src/lib/auth.ts
export const oidcConfig: AuthProviderProps = {
authority: ZITADEL_DOMAIN,
client_id: CLIENT_ID,
scope: "openid profile email offline_access",
// Fetch /oidc/v1/userinfo after login and merge claims into auth.user.profile.
// Reliable for email even when the address is unverified in Zitadel.
loadUserInfo: true,
// ...
};Danach ist auth.user.profile.email immer befüllt. Die Bibliothek ruft den Userinfo-Endpoint nach dem Login automatisch auf und mergt die Claims.
Chrome Extension (manueller PKCE-Flow)
Die Extension verwaltet Tokens selbst über chrome.storage.sync. Nach dem Token-Austausch ruft sie den Userinfo-Endpoint direkt auf:
// extension/src/auth/zitadel.ts
async function fetchUserEmail(accessToken: string): Promise<string> {
try {
const res = await fetch(`${ZITADEL_DOMAIN}/oidc/v1/userinfo`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) return "";
const profile = (await res.json()) as Record<string, unknown>;
return (profile.email as string) ?? (profile.preferred_username as string) ?? "";
} catch {
return "";
}
}
async function exchangeCodeForTokens(/* ... */): Promise<StoredTokens> {
// ... token exchange ...
const email = await fetchUserEmail(data.access_token);
return { accessToken, refreshToken, expiresAt, userId, email };
}Bei einem Token-Refresh gibt Zitadel keinen neuen id_token zurück. Der Userinfo-Aufruf mit dem neuen access_token funktioniert trotzdem. Als Fallback dient die gespeicherte E-Mail aus dem letzten erfolgreichen Login:
const email = (await fetchUserEmail(data.access_token)) || existingTokens?.email || "";Ist das sicher?
Ja. Der Userinfo-Endpoint mit dem access_token als Autorisierung ist genau das, was der OIDC Core Standard (Abschnitt 5.3) vorschreibt. Es ist kein Workaround.
Die wichtigen Punkte zur Sicherheit:
- Der
access_tokenist kurzlebig (typischerweise 1 Stunde), ein abgelaufener Token wird vom Identity Provider sofort abgelehnt - Der Userinfo-Endpoint ist read-only und gibt ausschließlich Daten des Token-Inhabers zurück
- Der Request ist ein direktes HTTPS-Fetch, er landet nicht in Browser-History oder URL-Logs
- Der
access_tokendarf nicht inlocalStoragegespeichert werden (XSS-Angriffsfläche). Die Frontend-Konfiguration nutzt dahersessionStorage(userStore: undefinedinreact-oidc-context)
Der einzige Nachteil: ein zusätzlicher HTTP-Request nach dem Login. Für eine Applikation, die sich einmal einloggt und dann stundenlang läuft, ist das vernachlässigbar.
Was sich geändert hat
Der Fehler saß im Mental Model, nicht im Code. Wer email aus dem access_token oder dem id_token liest, verlässt sich auf ein Implementierungsdetail des Identity Providers. Der Userinfo-Endpoint ist die Garantie, spezifiziert im Standard, konsistent über alle OIDC-Provider.
Die Regel ist einfach:
access_tokenan das Backend senden.id_tokenminimal für Session-Management nutzen. Profildaten immer vom Userinfo-Endpoint holen.
Alle Artikel der Serie
- Vision und Systemübersicht: Chrome Extension, RAG-Architektur, Projekthintergrund: Artikel lesen
- RAG-System Aufbau: Qdrant, Embeddings, Cosine-Ähnlichkeit in TypeScript: Artikel lesen
- AI Provider Abstraktion: Ollama vs. OpenAI, Interface-Design, kein Vendor-Lock-in: Artikel lesen
- Chrome Extension MV3: Drei isolierte Laufzeitkontexte, Message Passing, Strategy Pattern: Artikel lesen
- Docker Compose Strategie: Override-Pattern, von lokal zu Azure: Artikel lesen
- Ollama lokal vs. Docker: Die Entscheidung und ihre Konsequenzen: Artikel lesen
- Ollama Auto-Pull Entrypoint: Automatisiertes Modell-Setup beim Container-Start: Artikel lesen
- tsconfig und Vite:
Node16vs.bundler, warum Vite eigene Regeln hat: Artikel lesen - Instagram Caption mit MutationObserver vollständig laden: Artikel lesen
- Chrome Extension Foundation mit Health-Dot und Retry-Queue: Artikel lesen
- Phase 2 Features: Shadow DOM Overlay, Tailwind v4, Duplicate Detection: Artikel lesen
- Race Condition bei der Plattformerkennung: Wie ein UI-Event die Instagram-Erkennung bricht: Artikel lesen
- PostId-Extraktion in zwei Instagram-Layouts: querySelector vs. Ancestor-Traversal: Artikel lesen
- Instagram Karussell vollständig erfassen mit MutationObserver: Artikel lesen
- Notiz und Tags beim Screenshot-Speichern: Artikel lesen
- Instagram Tastatur-Shortcuts blockieren Chrome Extension Eingaben: Artikel lesen
- Lowercase-Normalisierung und Duplikat-Erkennung im Tag-Input: Artikel lesen
- Zitadel Login V2 in Docker Compose: drei versteckte Fehler: Artikel lesen
- PKCE OAuth in einer Chrome MV3 Extension: Artikel lesen
- React Frontend mit react-oidc-context und Zitadel: Artikel lesen
- Vite Build-Time-Umgebungsvariablen in Docker: Artikel lesen
- Event-Driven Ingestion mit BullMQ und Redis: Artikel lesen
- MinIO statt Azurite: S3-kompatible Objektspeicherung lokal und auf Hetzner: Artikel lesen
- access_token, id_token und der Userinfo-Endpoint (dieser Artikel)
Du baust ein System mit OIDC und fragst dich, welcher Token wohin gehört? Lass uns das gemeinsam anschauen.



