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:
| Kriterium | Keycloak | Authentik | Zitadel |
|---|---|---|---|
| RAM-Bedarf | 1-2 GB | ~500 MB | ~150 MB |
| Dependencies | Postgres/H2 | Postgres + Redis | Postgres |
| Primäres Protokoll | OIDC + SAML | OIDC + SAML | OIDC |
| Sprache | Java | Python | Go |
| Docker-Setup | Komplex | Mittel | Einfach |
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/logoutund/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:
| Vorher | Nachher |
|---|---|
| Intranet: Roles-Scope, go-redirect/once: nicht | Alle: Roles-Scope aktiv |
Intranet: roles: string[] in Session | Alle: roles: string[] in Session |
| Unterschiedliche Logout-Redirects | Konfigurierbar via postLogoutPath |
Session-Tabelle in jeder database.ts | Package 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.