Three Express apps, three login forms, three user databases, three password reset flows. That’s what identity management looks like when you don’t have one. Each app implements authentication on its own — with all the mistakes you can make along the way.

The alternative: a central Identity Provider that manages users, handles logins, and delivers verified identities to the applications. The app itself doesn’t store a single password.

What Identity Management Solves

Authentication sounds simple: email, password, done. In practice, it means: password hashing with the correct algorithm (bcrypt/scrypt/argon2), brute-force protection, password reset via email, session management, token rotation, potentially multi-factor authentication. That’s a lot of code that has nothing to do with the actual application — and a lot of attack surface.

Identity management centralizes all of this in one service:

  • One login for everything. Single Sign-On — a user logs in once and has access to all connected apps.
  • No password in the app. The application delegates the login to the Identity Provider and receives a signed token back. It never sees a password.
  • User management in one place. New employee? One account, assign roles, done. Employee leaves? Deactivate one account, all apps locked.
  • Standardized protocols. OpenID Connect instead of custom-built auth logic. Any OIDC-capable library works out of the box.

For a portfolio with three small services, this sounds like overkill. It’s not. As soon as more than one application needs authentication, an Identity Provider saves time and reduces security risks — whether it’s three apps or thirty.

Open-Source Alternatives

The market for open-source Identity Providers is manageable. Three projects dominate:

Keycloak

The market leader. Developed by Red Hat, on the market for over a decade. Java-based, massive feature set, large community. Supports OIDC, SAML, LDAP, social login, fine-grained policies.

Strengths: Extremely mature, every conceivable feature available, good documentation.

Weaknesses: Java monolith with corresponding resource requirements. Minimum 512 MB RAM at idle, realistically 1-2 GB. Configuration through a nested admin console that is oversized for simple setups. Updates can contain breaking changes.

Authentik

A more modern approach. Python/Django-based, with a focus on self-hosting and easy configuration. Supports OIDC, SAML, LDAP, and an integrated reverse proxy for header-based authentication.

Strengths: Simpler than Keycloak, good UI, active community, blueprint system for declarative configuration.

Weaknesses: Python is not the most performant choice for an auth service. Requires PostgreSQL plus Redis — two additional services for an authentication solution.

Zitadel

The newcomer. Written in Go, cloud-native, with a focus on developer experience. OIDC-first, RBAC, multi-tenant capable, integrated action flows for custom logic.

Strengths: Lean resource footprint (~150 MB RAM), single binary or Docker, PostgreSQL as the only dependency. Clean API, modern UI, OIDC implementation that adheres to the standard.

Weaknesses: Younger than Keycloak, smaller community. SAML support exists but is not the primary focus. Fewer third-party integrations.

Why Zitadel

For my use case — three small Express apps on an 8 GB Hetzner server — the decision was pragmatic:

CriterionKeycloakAuthentikZitadel
RAM usage1-2 GB~500 MB~150 MB
DependenciesPostgres/H2Postgres + RedisPostgres
Primary protocolOIDC + SAMLOIDC + SAMLOIDC
LanguageJavaPythonGo
Docker setupComplexMediumSimple

On a server that runs three applications, Nginx, and WireGuard alongside the Identity Provider, every megabyte counts. Keycloak would have claimed a quarter of the RAM for itself alone. Zitadel runs together with PostgreSQL in ~300 MB — leaving enough headroom for everything else.

Zitadel’s OIDC implementation is clean and standards-compliant. No fumbling with custom scopes or proprietary token formats. The openid-client library in Node.js works without workarounds.

The Implementation

Architecture

Zitadel runs as a Docker container on the web server, alongside a PostgreSQL instance. Nginx terminates SSL and routes traffic for auth.mathis-adler.dev to Zitadel:

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

Each app is registered in Zitadel as its own OIDC project with its own client ID. Roles are defined per project — go-redirect:admin, once:admin, zitadel:admin. A user only gets the roles they need.

The OIDC Flow

The login flow follows the standard OAuth 2.0 Authorization Code Flow with 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 protects the authorization code: the app generates a random code_verifier, sends its SHA-256 hash as code_challenge to Zitadel, and proves during the token exchange with the original verifier that it initiated the flow. An intercepted code is worthless without the verifier.

Roles via OIDC

Zitadel delivers roles in the ID token via a custom claim. The scope urn:zitadel:iam:org:project:roles activates their delivery:

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

The token then contains:

{
  "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" }
  }
}

The app extracts the role names as string[] — in this case ["go-redirect:admin", "once:admin"]. The intranet dashboard uses these roles to display only the app tiles the user has access to. Role-Based Access Control without custom role management in every app.

Back-Channel Logout

When a user logs out of one app, they shouldn’t remain logged in to the others. Back-Channel Logout solves this: Zitadel notifies all registered apps via POST request that a session has ended.

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

The logout token is a JWT with the user’s sub claim. The app searches for all sessions with that user_sub in SQLite and deletes them. On the next request, the user is redirected to the login.

The Shared Package

The Problem: The Same Code Three Times

Before the refactoring, each app had its own copy of the auth logic. Nine files that differed only in three configuration values. A bug fix in the OIDC logic had to be applied in three repos simultaneously. That’s not a maintainable state.

The Solution: @mathis-qds/zitadel-auth

A custom npm package that encapsulates all of the auth logic. One function, three return values:

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,
    defaultRedirect: "/admin",
    postLogoutPath: "/admin",
  });

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

The package provides:

  • sessionMiddleware — Express session with SQLite store, automatic cleanup every 15 minutes, httpOnly/secure/sameSite cookies.
  • router — Express router with /auth/callback, /auth/logout, and /oidc/backchannel-logout.
  • ensureAuthenticated — Middleware that redirects unauthenticated requests to the Zitadel login and sends them back to the original URL after successful authentication.

One bug fix, one npm pack, three npm install — done. No copy-paste errors, no forgotten repos.

Conclusion

Identity management is not an enterprise feature. As soon as more than one application needs authentication, a central Identity Provider is justified — even on a small self-hosting setup. Zitadel makes this accessible with minimal resource requirements and a clean OIDC implementation.

The real work isn’t in connecting to Zitadel — that’s done in a few lines with openid-client. It’s in the details: PKCE for secure code exchanges, back-channel logout for consistent sessions, and a shared package that prevents the same logic from diverging across three repos.

The result: three apps, one login, one user management, zero passwords in application code.

Glossary

OpenID Connect (OIDC)
An authentication protocol based on OAuth 2.0. The Identity Provider confirms a user's identity and returns information such as name, email, and roles in a signed ID token.
Single Sign-On (SSO)
An authentication method where a user logs in once and can then access multiple applications without logging in again. The Identity Provider manages the central session.
PKCE
Proof Key for Code Exchange — an extension of the OAuth 2.0 Authorization Code Flow that secures the exchange of the authorization code with a cryptographic verifier. Prevents an intercepted code from being redeemed by an attacker.
OAuth 2.0
An authorization framework that allows applications to access resources on behalf of a user without knowing their password. Forms the foundation for OpenID Connect and many login-with-third-party flows.
Role-Based Access Control (RBAC)
An access model where permissions are assigned to roles rather than individual users. A user gains access by being assigned a role — e.g., admin, editor, or viewer.
Back-Channel Logout
A mechanism where the Identity Provider notifies all connected applications directly via server-to-server request when a user logs out. The applications then invalidate the local session — without the user having to visit each app individually.
Identity Provider (IdP)
A central service that manages user identities and performs authentication. Applications delegate the login to the IdP and receive verified information about the user in return.
Docker Container
An isolated runtime environment that packages an application with all its dependencies. Containers share the host system's kernel, start in milliseconds, and guarantee that software runs the same everywhere — locally and on the server.