Zitadel Provider Setup
Zitadel runs through a Custom OIDC slot with OAUTH2_CUSTOM{N}_PRESET=zitadel
— the preset supplies the display name, URL segment (zitadel), icon, and
brand colors. Setting the preset is equivalent to configuring a bespoke
“Zitadel” provider; no code change is required.
Zitadel is a full-featured OIDC/OAuth2 identity platform. It provisions a
PostgreSQL backend on first boot, runs initial migrations, and starts the
server on :8080.
Prerequisites
- Docker and Docker Compose
- A running oauth2-passkey application
Step 1: Bring Up Zitadel
A docker-compose.yaml is provided in idp/zitadel/ (v2.71.x, embedded V1
login) and idp/zitadel-v4/ (v4.x, separate zitadel-login Next.js
service). The two stacks are mutually exclusive — both bind to :8080.
cd idp/zitadel
docker compose up -d
docker compose logs -f zitadel # wait for "server is listening on [::]:8080"
First start takes ~30-60 seconds (init + setup + start). Subsequent boots are near-instant.
Open http://localhost:8080/ui/console to access the admin console.
Why v2.71.x and not
latest? Zitadel v3+ redirects/ui/consoleto the separatezitadel-loginNext.js service, which is not bundled with the main container. v2.71.x still ships the embedded V1 login UI and works standalone. Use the v4 stack to test the multi-service setup. Seeidp/README.mdfor stack details.
Step 2: Register an Application
- Log into the Zitadel console (default IAM owner credentials are
zitadel-admin@zitadel.localhost/Password1!— Zitadel prompts a password reset on first login). The actual password is also printed in the container logs:docker compose logs zitadel | grep -A2 "IAM Owner" - Open the Default project (or create one) → New → Application.
- Choose Web.
- Authentication Method: pick
CODE— this is what issues a client secret. Do not pickPKCE: PKCE makes Zitadel treat the app as a public client with no secret, which oauth2-passkey does not use. If you pickedPKCEby accident, open the app, switch Authentication Method toCODE, save, then regenerate a secret from the Client Secret section. - Set Redirect URI:
http://localhost:3001/o2p/oauth2/zitadel/authorized - Finish the wizard and copy the Client ID and Client Secret. The secret is shown once only — if you miss it, regenerate it from the app’s detail page → Client Secret section.
Step 3: Configure Environment Variables
Add the following to your .env file. This example uses slot 1; any of
slots 1..8 works (each slot is independent).
OAUTH2_CUSTOM1_PRESET=zitadel
OAUTH2_CUSTOM1_CLIENT_ID='<client-id-from-zitadel>'
OAUTH2_CUSTOM1_CLIENT_SECRET='<client-secret-from-zitadel>'
OAUTH2_CUSTOM1_ISSUER_URL='http://localhost:8080'
OAUTH2_CUSTOM1_RESPONSE_MODE='query'
The preset (PRESET=zitadel) fills in defaults for DISPLAY_NAME, NAME
(which becomes the zitadel URL segment), ICON_SLUG, and button colors.
RESPONSE_MODE=queryfor Zitadel v4: v4’s separatezitadel-loginNext.js service silently downgradesresponse_mode=form_postto a query redirect, which oauth2-passkey then rejects. SetOAUTH2_CUSTOM1_RESPONSE_MODE=queryfor v4. v2 (embedded V1 login,v2.71.x) handlesform_postnatively and the line can be omitted — but setting it explicitly is harmless and keeps the env file portable across versions. See the Zitadel form_post troubleshooting entry for the upstream root cause.
Optional overrides (defaults shown):
# Default: 'openid+email+profile'
#OAUTH2_CUSTOM1_SCOPE='openid+email+profile'
# Override any preset field:
#OAUTH2_CUSTOM1_NAME='zitadel-prod'
#OAUTH2_CUSTOM1_DISPLAY_NAME='Zitadel Production'
Step 4: Verify
Start your application and navigate to the login page. A Zitadel button should appear alongside Google.
After logging in via Zitadel, verify the database row:
# PostgreSQL
psql $DATABASE_URL -c "SELECT provider, provider_user_id, email FROM o2p_oauth2_accounts ORDER BY created_at DESC LIMIT 3;"
# SQLite
sqlite3 db/sqlite/data/data.db "SELECT provider, provider_user_id, email FROM o2p_oauth2_accounts ORDER BY created_at DESC LIMIT 3;"
Expected output:
provider | provider_user_id | email
----------+--------------------------------------------+------------------
zitadel | zitadel_322481474651946242 | user@example.com
Notes
- The
provider_user_idformat iszitadel_{sub}wheresubis the Zitadel user identifier. emailis absent from the ID token; oauth2-passkey reads it from the/userinfoendpoint.- Zitadel v2 vs v4 share the same
:8080port andjwks_uri. If you switch versions on the same host, see JWKS stale after switching IdP versions. - If sign-in skips the password / account-picker screen on subsequent logins (Zitadel’s SSO behavior), see IdP signs the user in without prompting.
- See Generic OIDC Provider Setup for the full Custom slot reference, including how presets and env-var overrides compose.