Neu veröffentlicht: E-Commerce mit Power Pages, Stripe & Analytics

· 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.

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_tokenid_token
FrageWas darf dieser Aufrufer?Wer hat sich gerade eingeloggt?
Für wenDein Backend-ServerDeine Frontend-Applikation
InhaltBerechtigungen, Scopes, sub (User-ID)Nutzerprofil: email, name, preferred_username
StandardOAuth 2.0OpenID Connect (OIDC), aufgebaut auf OAuth 2.0
Du sendest es…Im Authorization: Bearer-Header an dein BackendGar 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_token enthä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_token ist 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_token darf nicht in localStorage gespeichert werden (XSS-Angriffsfläche). Die Frontend-Konfiguration nutzt daher sessionStorage (userStore: undefined in react-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_token an das Backend senden. id_token minimal für Session-Management nutzen. Profildaten immer vom Userinfo-Endpoint holen.


Alle Artikel der Serie

  1. Vision und Systemübersicht: Chrome Extension, RAG-Architektur, Projekthintergrund: Artikel lesen
  2. RAG-System Aufbau: Qdrant, Embeddings, Cosine-Ähnlichkeit in TypeScript: Artikel lesen
  3. AI Provider Abstraktion: Ollama vs. OpenAI, Interface-Design, kein Vendor-Lock-in: Artikel lesen
  4. Chrome Extension MV3: Drei isolierte Laufzeitkontexte, Message Passing, Strategy Pattern: Artikel lesen
  5. Docker Compose Strategie: Override-Pattern, von lokal zu Azure: Artikel lesen
  6. Ollama lokal vs. Docker: Die Entscheidung und ihre Konsequenzen: Artikel lesen
  7. Ollama Auto-Pull Entrypoint: Automatisiertes Modell-Setup beim Container-Start: Artikel lesen
  8. tsconfig und Vite: Node16 vs. bundler, warum Vite eigene Regeln hat: Artikel lesen
  9. Instagram Caption mit MutationObserver vollständig laden: Artikel lesen
  10. Chrome Extension Foundation mit Health-Dot und Retry-Queue: Artikel lesen
  11. Phase 2 Features: Shadow DOM Overlay, Tailwind v4, Duplicate Detection: Artikel lesen
  12. Race Condition bei der Plattformerkennung: Wie ein UI-Event die Instagram-Erkennung bricht: Artikel lesen
  13. PostId-Extraktion in zwei Instagram-Layouts: querySelector vs. Ancestor-Traversal: Artikel lesen
  14. Instagram Karussell vollständig erfassen mit MutationObserver: Artikel lesen
  15. Notiz und Tags beim Screenshot-Speichern: Artikel lesen
  16. Instagram Tastatur-Shortcuts blockieren Chrome Extension Eingaben: Artikel lesen
  17. Lowercase-Normalisierung und Duplikat-Erkennung im Tag-Input: Artikel lesen
  18. Zitadel Login V2 in Docker Compose: drei versteckte Fehler: Artikel lesen
  19. PKCE OAuth in einer Chrome MV3 Extension: Artikel lesen
  20. React Frontend mit react-oidc-context und Zitadel: Artikel lesen
  21. Vite Build-Time-Umgebungsvariablen in Docker: Artikel lesen
  22. Event-Driven Ingestion mit BullMQ und Redis: Artikel lesen
  23. MinIO statt Azurite: S3-kompatible Objektspeicherung lokal und auf Hetzner: Artikel lesen
  24. 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.

Zurück zum Blog

Ähnliche Beiträge

Alle Beiträge ansehen
Event-Driven Ingestion mit BullMQ und Redis

Event-Driven Ingestion mit BullMQ und Redis

POST /ingest blockierte die Extension, bis Embedding und Qdrant-Upsert fertig waren. Mit BullMQ und Redis wird der Ingest asynchron: 202 sofort, Verarbeitung im Hintergrund, Statusabfrage über GET /captures/:id/status.