Drei Express-Apps, drei Login-Formulare, drei Nutzerdatenbanken, drei Passwort-Reset-Flows. So sieht Identity Management aus, wenn man keins hat. Jede App implementiert Authentifizierung selbst — mit allen Fehlern, die man dabei machen kann.

Die Alternative: Ein zentraler Identity Provider , der Nutzer verwaltet, Logins durchführt und den Anwendungen verifizierte Identitäten liefert. Die App selbst speichert kein einziges Passwort.

Was Identity Management löst

Authentifizierung klingt einfach: E-Mail, Passwort, fertig. In der Praxis bedeutet es: Passwort-Hashing mit korrektem Algorithmus (bcrypt/scrypt/argon2), Brute-Force-Schutz, Passwort-Reset per E-Mail, Session-Management, Token-Rotation, eventuell Multi-Factor Authentication. Das ist viel Code, der nichts mit der eigentlichen Anwendung zu tun hat — und viel Angriffsfläche.

Identity Management zentralisiert das alles in einem Dienst:

  • Ein Login für alles. Single Sign-On — ein Nutzer meldet sich einmal an und hat Zugriff auf alle verbundenen Apps.
  • Kein Passwort in der App. Die Anwendung delegiert den Login an den Identity Provider und bekommt ein signiertes Token zurück. Sie sieht nie ein Passwort.
  • Nutzerverwaltung an einer Stelle. Neuer Mitarbeiter? Ein Account, Rollen zuweisen, fertig. Mitarbeiter geht? Ein Account deaktivieren, alle Apps gesperrt.
  • Standardisierte Protokolle. OpenID Connect statt selbstgebauter Auth-Logik. Jede OIDC-fähige Library funktioniert out of the box.

Für ein Portfolio mit drei kleinen Services klingt das nach Overkill. Ist es nicht. Sobald mehr als eine Anwendung Authentifizierung braucht, spart ein Identity Provider Zeit und reduziert Sicherheitsrisiken — egal ob drei Apps oder dreißig.

Open-Source-Alternativen

Der Markt für Open-Source Identity Provider ist überschaubar. Drei Projekte dominieren:

Keycloak

Der Platzhirsch. Von Red Hat entwickelt, seit über einem Jahrzehnt am Markt. Java-basiert, riesiger Feature-Umfang, große Community. Supports OIDC, SAML, LDAP, Social Login, feingranulare Policies.

Stärken: Extrem ausgereift, jedes erdenkliche Feature vorhanden, gute Dokumentation.

Schwächen: Java-Monolith mit entsprechendem Ressourcenbedarf. Minimum 512 MB RAM im Leerlauf, realistisch 1-2 GB. Konfiguration über eine verschachtelte Admin-Konsole, die für einfache Setups überdimensioniert ist. Updates können Breaking Changes enthalten.

Authentik

Ein modernerer Ansatz. Python/Django-basiert, mit einem Fokus auf Self-Hosting und einfache Konfiguration. Unterstützt OIDC, SAML, LDAP und einen integrierten Reverse Proxy für Header-basierte Authentifizierung.

Stärken: Einfacher als Keycloak, gute UI, aktive Community, Blueprint-System für deklarative Konfiguration.

Schwächen: Python ist nicht die performanteste Wahl für einen Auth-Service. Braucht PostgreSQL plus Redis — zwei zusätzliche Dienste für eine Authentifizierungslösung.

Zitadel

Der Neuling. In Go geschrieben, Cloud-native, mit einem Fokus auf Developer Experience. OIDC-first, RBAC, Multitenant-fähig, integrierte Action Flows für Custom Logic.

Stärken: Schlanker Ressourcenbedarf (~150 MB RAM), single Binary oder Docker, PostgreSQL als einzige Abhängigkeit. Saubere API, modernes UI, OIDC-Implementierung, die sich an den Standard hält.

Schwächen: Jünger als Keycloak, kleinere Community. SAML-Support existiert, ist aber nicht der primäre Fokus. Weniger Third-Party-Integrationen.

Warum Zitadel

Für meinen Anwendungsfall — drei kleine Express-Apps auf einem 8-GB-Hetzner-Server — war die Entscheidung pragmatisch:

KriteriumKeycloakAuthentikZitadel
RAM-Bedarf1-2 GB~500 MB~150 MB
DependenciesPostgres/H2Postgres + RedisPostgres
Primäres ProtokollOIDC + SAMLOIDC + SAMLOIDC
SpracheJavaPythonGo
Docker-SetupKomplexMittelEinfach

Auf einem Server, der neben dem Identity Provider noch drei Anwendungen, Nginx, und WireGuard betreibt, zählt jedes Megabyte. Keycloak hätte ein Viertel des RAMs allein für sich beansprucht. Zitadel läuft mit PostgreSQL zusammen in ~300 MB — und lässt genug Luft für alles andere.

Die OIDC-Implementierung von Zitadel ist sauber und standardkonform. Kein Fumbling mit Custom Scopes oder proprietären Token-Formaten. Die openid-client Library in Node.js funktioniert ohne Workarounds.

Die Implementierung

Architektur

Zitadel läuft als Docker Container auf dem Webserver, zusammen mit einer PostgreSQL-Instanz. Nginx terminiert SSL und leitet Traffic für auth.mathis-adler.dev an Zitadel weiter:

Browser → Nginx (SSL) → Zitadel (:8080)
                       → go-redirect (:3001)
                       → once (:3002)
                       → intranet (:3003)

Jede App ist in Zitadel als eigenes OIDC-Projekt mit eigener Client-ID registriert. Rollen werden pro Projekt definiert — go-redirect:admin, once:admin, zitadel:admin. Ein Nutzer bekommt nur die Rollen, die er braucht.

Der OIDC-Flow

Der Login-Flow folgt dem Standard OAuth 2.0 Authorization Code Flow mit PKCE :

1. Nutzer besucht /admin
2. App: Kein Session-Cookie → Redirect zu Zitadel
3. Zitadel: Login-Formular (oder bestehende SSO-Session)
4. Zitadel: Redirect zurück mit Authorization Code
5. App: Tauscht Code gegen ID-Token (Server-to-Server)
6. App: Extrahiert sub, email, name, roles aus dem Token
7. App: Erstellt lokale Session mit SQLite

PKCE schützt den Authorization Code: Die App generiert einen zufälligen code_verifier, sendet dessen SHA-256-Hash als code_challenge an Zitadel, und beweist beim Token-Austausch mit dem originalen Verifier, dass sie den Flow initiiert hat. Ein abgefangener Code ist ohne den Verifier wertlos.

Rollen über OIDC

Zitadel liefert Rollen im ID-Token über einen Custom Claim. Der Scope urn:zitadel:iam:org:project:roles aktiviert die Auslieferung:

scope: "openid profile email urn:zitadel:iam:org:project:roles"

Das Token enthält dann:

{
  "sub": "284925...",
  "email": "mathis@example.com",
  "urn:zitadel:iam:org:project:roles": {
    "go-redirect:admin": { "284...": "mathis-adler.dev" },
    "once:admin": { "284...": "mathis-adler.dev" }
  }
}

Die App extrahiert die Rollennamen als string[] — in diesem Fall ["go-redirect:admin", "once:admin"]. Das Intranet-Dashboard nutzt diese Rollen, um nur die App-Tiles anzuzeigen, auf die der Nutzer Zugriff hat. Role-Based Access Control ohne eigene Rollenverwaltung in jeder App.

Back-Channel Logout

Wenn sich ein Nutzer in einer App abmeldet, soll er nicht in den anderen Apps eingeloggt bleiben. Back-Channel Logout löst das: Zitadel benachrichtigt alle registrierten Apps per POST-Request, dass eine Session beendet wurde.

Nutzer klickt "Logout" in go-redirect
  → go-redirect: Session löschen, Redirect zu Zitadel /end_session
  → Zitadel: SSO-Session beenden
  → Zitadel: POST /oidc/backchannel-logout an once
  → Zitadel: POST /oidc/backchannel-logout an intranet
  → once + intranet: Sessions für diesen Nutzer löschen

Der Logout-Token ist ein JWT mit der sub Claim des Nutzers. Die App sucht alle Sessions mit dieser user_sub in SQLite und löscht sie. Beim nächsten Request wird der Nutzer zum Login weitergeleitet.

Das Shared Package

Das Problem: Dreimal derselbe Code

Vor dem Refactoring hatte jede App ihre eigene Kopie der Auth-Logik:

go-redirect/src/auth/oidc.ts        ← fast identisch
once/src/auth/oidc.ts                ← fast identisch
intranet/src/auth/oidc.ts            ← fast identisch

go-redirect/src/auth/session.ts      ← fast identisch
once/src/auth/session.ts             ← fast identisch
intranet/src/auth/session.ts         ← fast identisch

go-redirect/src/routes/auth.ts       ← fast identisch
once/src/routes/auth.ts              ← fast identisch
intranet/src/routes/auth.ts          ← fast identisch

Neun Dateien, die sich nur in drei Konfigurationswerten unterschieden: der Default-Redirect nach Login, der Redirect nach Logout, und ob der Roles-Scope angefordert wird. Ein Bug-Fix in der OIDC-Logik musste in drei Repos gleichzeitig gemacht werden. Das ist kein wartbarer Zustand.

Die Lösung: @mathis-qds/zitadel-auth

Ein eigenes npm-Package, das die gesamte Auth-Logik kapselt. Eine Funktion, drei Rückgabewerte:

import { createZitadelAuth, isOidcConfigured } from "@mathis-qds/zitadel-auth";

if (isOidcConfigured(config.oidc, config.sessionSecret)) {
  const auth = createZitadelAuth({
    ...config.oidc,
    sessionSecret: config.sessionSecret,
    baseUrl: config.baseUrl,
    db,                          // better-sqlite3 Instance
    defaultRedirect: "/admin",   // nach Login
    postLogoutPath: "/admin",    // nach Logout
  });

  app.use(["/admin", "/auth", "/oidc"], auth.sessionMiddleware);
  app.use("/admin", auth.ensureAuthenticated);
  app.use(auth.router);
}

Das Package liefert:

  • sessionMiddleware — Express-Session mit SQLite-Store, automatischem Cleanup alle 15 Minuten, httpOnly/secure/sameSite-Cookies.
  • router — Express-Router mit /auth/callback, /auth/logout und /oidc/backchannel-logout.
  • ensureAuthenticated — Middleware, die unauthentifizierte Requests zum Zitadel-Login weiterleitet und nach erfolgreicher Anmeldung zurück zur ursprünglichen URL schickt.

Was sich standardisiert hat

Durch das Package nutzen jetzt alle drei Apps exakt dieselbe Logik:

VorherNachher
Intranet: Roles-Scope, go-redirect/once: nichtAlle: Roles-Scope aktiv
Intranet: roles: string[] in SessionAlle: roles: string[] in Session
Unterschiedliche Logout-RedirectsKonfigurierbar via postLogoutPath
Session-Tabelle in jeder database.tsPackage erstellt Tabelle automatisch

Ein Bug-Fix, ein npm pack, drei npm install — fertig. Keine Copy-Paste-Fehler, keine vergessenen Repos.

Package-Struktur

zitadel-auth/
├── src/
│   ├── index.ts        createZitadelAuth(), isOidcConfigured()
│   ├── types.ts        ZitadelAuthConfig, ZitadelUser, Session-Augmentation
│   ├── oidc.ts         Discovery, Auth-URL, Code-Exchange
│   ├── session.ts      SQLiteStore, Session-Middleware
│   ├── middleware.ts    ensureAuthenticated Factory
│   └── router.ts       Auth-Router Factory (Callback, Logout, Backchannel)

Sechs Dateien, ~200 Zeilen Code. TypeScript mit Deklarationen, sodass die konsumierenden Apps volle Typunterstützung haben — inklusive req.session.user mit sub, email, name und roles.

Fazit

Identity Management ist kein Enterprise-Feature. Sobald mehr als eine Anwendung Authentifizierung braucht, rechtfertigt sich ein zentraler Identity Provider — selbst auf einem kleinen Self-Hosting-Setup. Zitadel macht das mit minimalem Ressourcenbedarf und einer sauberen OIDC-Implementierung zugänglich.

Die eigentliche Arbeit steckt nicht in der Anbindung an Zitadel — die ist mit openid-client in wenigen Zeilen erledigt. Sie steckt in den Details: PKCE für sichere Code-Exchanges, Back-Channel Logout für konsistente Sessions, und ein Shared Package, das verhindert, dass dieselbe Logik in drei Repos divergiert.

Das Ergebnis: Drei Apps, ein Login, eine Nutzerverwaltung, null Passwörter in Anwendungscode.

Glossar

OpenID Connect (OIDC)
Ein Authentifizierungsprotokoll auf Basis von OAuth 2.0. Der Identity Provider bestätigt die Identität eines Nutzers und liefert Informationen wie Name, E-Mail und Rollen in einem signierten ID-Token zurück.
Single Sign-On (SSO)
Ein Authentifizierungsverfahren, bei dem sich ein Nutzer einmalig anmeldet und danach auf mehrere Anwendungen zugreifen kann, ohne sich erneut einloggen zu müssen. Der Identity Provider verwaltet die zentrale Session.
PKCE
Proof Key for Code Exchange — eine Erweiterung des OAuth 2.0 Authorization Code Flows, die den Austausch des Authorization Codes mit einem kryptografischen Verifier absichert. Verhindert, dass ein abgefangener Code von einem Angreifer eingelöst werden kann.
OAuth 2.0
Ein Autorisierungsframework, das Anwendungen erlaubt, im Namen eines Nutzers auf Ressourcen zuzugreifen, ohne dessen Passwort zu kennen. Bildet die Grundlage für OpenID Connect und viele Login-mit-Drittanbieter-Flows.
Role-Based Access Control (RBAC)
Ein Zugriffsmodell, bei dem Berechtigungen nicht einzelnen Nutzern, sondern Rollen zugewiesen werden. Ein Nutzer erhält Zugriff, indem er einer Rolle zugeordnet wird — z.B. admin, editor oder viewer.
Back-Channel Logout
Ein Mechanismus, bei dem der Identity Provider beim Logout eines Nutzers alle verbundenen Anwendungen direkt per Server-zu-Server-Request benachrichtigt. Die Anwendungen invalidieren daraufhin die lokale Session — ohne dass der Nutzer jede App einzeln besuchen muss.
Identity Provider (IdP)
Ein zentraler Dienst, der Nutzeridentitäten verwaltet und Authentifizierung durchführt. Anwendungen delegieren den Login an den IdP und erhalten im Gegenzug verifizierte Informationen über den Nutzer.
Docker Container
Eine isolierte Laufzeitumgebung, die eine Anwendung mit allen Abhängigkeiten verpackt. Container teilen sich den Kernel des Host-Systems, starten in Millisekunden und garantieren, dass Software überall gleich läuft — lokal wie auf dem Server.