· Webentwicklung · 5 minuten Lesezeit
React-Frontend mit react-oidc-context und Zitadel: sechs Auth-Zustände sauber verwalten
react-oidc-context übernimmt den PKCE-Flow im Browser. Die eigentliche Herausforderung ist nicht der Login-Button, sondern die korrekte Behandlung aller sechs Auth-Zustände in App.tsx und eine JWT-Middleware im Backend, die Zitadels JWKS-Endpoint nutzt.

Inhalt
- react-oidc-context als OIDC-Client
- Die sechs Auth-Zustände in App.tsx
- Der Login-Button
- JWT-Middleware im Backend
- AUTH_ENABLED=false für lokale Entwicklung
- Verifizierung: auth-disabled aus dem Bundle entfernen
- Alle Artikel der Serie
react-oidc-context als OIDC-Client
Die Bibliothek react-oidc-context wrappt oidc-client-ts und stellt einen React Context bereit. Der Setup ist minimal:
// src/lib/auth.ts
export const AUTH_ENABLED = import.meta.env.VITE_AUTH_ENABLED === 'true';
const CLIENT_ID = import.meta.env.VITE_ZITADEL_CLIENT_ID;
const ZITADEL_DOMAIN = import.meta.env.VITE_ZITADEL_DOMAIN;
export const oidcConfig: AuthProviderProps = {
authority: ZITADEL_DOMAIN,
client_id: AUTH_ENABLED ? CLIENT_ID : 'auth-disabled',
redirect_uri: `${window.location.origin}/callback`,
scope: 'openid profile email',
response_type: 'code',
};Der 'auth-disabled'-Placeholder ist wichtig: Wenn AUTH_ENABLED=false, erwartet oidc-client-ts trotzdem eine nicht-leere client_id. Ohne Placeholder würde die Bibliothek beim Initialisieren einen Fehler werfen, auch wenn Auth komplett deaktiviert ist.
In main.tsx wird der Provider gesetzt:
import { AuthProvider } from 'react-oidc-context';
import { oidcConfig } from './lib/auth';
ReactDOM.createRoot(document.getElementById('root')!).render(
<AuthProvider {...oidcConfig}>
<App />
</AuthProvider>
);AuthProvider lädt automatisch die OIDC-Konfiguration vom /.well-known/openid-configuration-Endpoint von Zitadel und verwaltet den Token-Lebenszyklus.
Die sechs Auth-Zustände in App.tsx
Der häufige Fehler beim Einbinden von react-oidc-context: Es wird nur zwischen “eingeloggt” und “nicht eingeloggt” unterschieden. In der Praxis gibt es sechs relevante Zustände:
const App = () => {
const auth = useAuth();
// 1. Auth ist deaktiviert: direkt in die App
if (!AUTH_ENABLED) {
return <Dashboard />;
}
// 2. Auth lädt: OIDC-Konfiguration wird vom Server geholt
if (auth.isLoading) {
return <LoadingSpinner />;
}
// 3. Auth-Fehler: z.B. Zitadel nicht erreichbar
if (auth.error) {
return <ErrorPage message={auth.error.message} />;
}
// 4. Callback-Verarbeitung: Code gegen Token tauschen
if (hasAuthParams()) {
return <LoadingSpinner message="Anmeldung wird abgeschlossen..." />;
}
// 5. Nicht authentifiziert: Login-Seite zeigen
if (!auth.isAuthenticated) {
return <LoginPage />;
}
// 6. Authentifiziert: App anzeigen
return <Dashboard user={auth.user} />;
};hasAuthParams() prüft, ob die aktuelle URL einen code-Parameter enthält, also ob der Redirect von Zitadel gerade verarbeitet wird. Ohne diesen Check flackert die App kurz die Login-Seite an, bevor die Callback-Verarbeitung abgeschlossen ist.
const hasAuthParams = (): boolean => {
const searchParams = new URLSearchParams(window.location.search);
return searchParams.has('code') || searchParams.has('error');
};Der Login-Button
Mit react-oidc-context ist der Login-Button zwei Zeilen:
const LoginPage = () => {
const auth = useAuth();
return <button onClick={() => auth.signinRedirect()}>Mit Zitadel anmelden</button>;
};signinRedirect() baut die PKCE Authorization URL (inklusive code_verifier und code_challenge), speichert den State in sessionStorage und leitet den Browser zur Zitadel Login V2 UI weiter. Nach dem Login kommt der Nutzer zurück zur redirect_uri.
Der Callback ist kein separater React-Router-Route. AuthProvider verarbeitet den Callback automatisch, wenn hasAuthParams() zutrifft.
JWT-Middleware im Backend
Das React-Frontend sendet bei jedem API-Call das Access Token als Bearer-Token:
// api-client.ts
const getAuthHeaders = async (): Promise<Record<string, string>> => {
const auth = getAuthContext(); // aus dem React Context
if (!auth.isAuthenticated || !auth.user?.access_token) return {};
return { Authorization: `Bearer ${auth.user.access_token}` };
};Das Backend validiert dieses Token gegen Zitadels JWKS-Endpoint:
// backend/src/middleware/auth.ts
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS_URL = `${process.env.ZITADEL_DOMAIN}/oauth/v2/keys`;
const JWKS = createRemoteJWKSet(new URL(JWKS_URL));
export const authMiddleware = async (req, res, next) => {
if (process.env.AUTH_ENABLED === 'false') {
req.auth = { userId: 'dev-user-local', email: 'dev@localhost' };
return next();
}
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' });
}
const token = authHeader.slice(7);
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: process.env.ZITADEL_DOMAIN,
});
req.auth = {
userId: payload.sub,
email: payload.email as string,
};
next();
} catch (error) {
return res.status(401).json({ error: 'Invalid token' });
}
};createRemoteJWKSet cached den öffentlichen Schlüssel von Zitadels JWKS-Endpoint. Das JWKS wird nicht bei jedem Request neu geladen. Bei einem Schlüssel-Rotation-Event holt jose automatisch den neuen Schlüssel.
Die userId aus dem JWT-sub-Claim wird dann in allen Datenbankoperationen verwendet. Sie stammt nicht aus dem Request-Body, sondern ausschließlich aus dem validierten Token. Das verhindert, dass ein Nutzer Daten eines anderen Nutzers manipulieren kann.
AUTH_ENABLED=false für lokale Entwicklung
Wenn AUTH_ENABLED=false (Backend), injiziert die Middleware eine synthetische Identität:
req.auth = { userId: 'dev-user-local', email: 'dev@localhost' };Das bedeutet: Alle Daten, die lokal ohne Auth erstellt wurden, gehören zu dev-user-local. Sobald Auth aktiviert wird, hat der echte Nutzer eine andere userId. Bestehende lokale Testdaten sind dann für den eingeloggten Nutzer unsichtbar. Das ist gewollt.
Verifizierung: auth-disabled aus dem Bundle entfernen
Nach der Einrichtung gibt es eine einfache Methode, um zu prüfen, ob Auth wirklich aktiviert ist:
# Im Docker-Build oder nach npm run build:
grep -r "auth-disabled" dist/Wenn AUTH_ENABLED=true korrekt gesetzt war, kommt kein Treffer. Der 'auth-disabled'-Placeholder landet nicht im Bundle, weil Vite den Branch zur Compile-Zeit entfernt, wenn VITE_AUTH_ENABLED === 'true' immer false ergibt.
Wenn doch ein Treffer kommt: frontend/.env wurde nicht richtig in den Docker-Build-Context einbezogen. Dazu mehr im Artikel über Vite-Umgebungsvariablen in Docker.
Abbildung: Links die Login-Seite mit Zitadel-Button, rechts das Dashboard nach erfolgreichem Login mit dem echten Zitadel-Benutzernamen in der Navigation.
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 (dieser Artikel)
- Vite Build-Time-Umgebungsvariablen in Docker: Artikel lesen
Du baust ein OIDC-gesichertes React-Frontend mit eigenem Identity Provider? Lass uns das gemeinsam einschätzen.



