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

· Architektur  · 6 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: was wohin gehört: Artikel lesen

  25. Qdrant Multi-Tenancy: Pro Nutzer eine eigene Collection: Artikel lesen

  26. Wenn Backend und Frontend unterschiedliche Typen kennen: Artikel lesen

  27. Zitadel Bootstrap entfernt: Host-Header-Bug und manuelles Setup: Artikel lesen

  28. Backend Code Review: sechs Probleme vor dem Launch behoben: Artikel lesen

  29. Traefik statt NGINX: Reverse Proxy für einen wachsenden Docker-Compose-Stack: Artikel lesen

  30. Zweischichtiges Rate Limiting: Traefik und express-rate-limit mit Redis: Artikel lesen

  31. DSGVO Art. 17 korrekt implementieren: Promise.allSettled und Export-Batching: Artikel lesen

  32. Embedding-Modell-Lock-in: Warum mxbai-embed-large eine Produktionsentscheidung für immer ist: Artikel lesen

  33. Docker Volumes in Produktion: Named Volumes, Bind Mounts und der Hetzner-Volume-Trick: Artikel lesen

  34. Zwei Sicherheitslücken vor dem Launch: Redis ohne Auth und ein offener Qdrant-Admin-Port: Artikel lesen

  35. Traefik als einziger Einstiegspunkt im Docker Compose Stack: Artikel lesen

  36. Zitadel hinter Traefik richtig verdrahten mit Issuer, JWKS und Login V2: Artikel lesen

  37. Frontend gesund machen wenn der nginx Healthcheck an localhost scheitert: Artikel lesen


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.