>_devkit
zitadel-production
skills/zitadel-production/

references/integration.md

Integration

Three surfaces, not one

Most people think only of the middle row, then write application code for the bottom row that Terraform already solved.

SurfaceWho does itWith what
Login: browser or device to ZITADELFrontend, or your BFFOIDC Authorization Code + PKCE
Token validation: your API verifies the JWTBackendJWKS, cached, verified locally
Provisioning: organizations, projects, rolesInfrastructure as codeTerraform provider

The middle row is the reusable asset and is near-identical across languages: same contract (verifyToken to Principal to resolveUser to requirePermission), different idiom.

Registering an application

Match the auth method to your client, not to the security ranking

This is the mistake worth naming first. The application's authentication method must match what your client library can actually send:

ClientMethodWhy
Backend you wrote (BFF)Private Key JWTStrongest, and you control the token request
A library that only knows client secretsBasicAuth.js, most generic OIDC clients
Browser SPANone (PKCE)Cannot hold a secret
Mobile or CLINone (PKCE)Cannot hold a secret

Configure Private Key JWT and then point a secret-only library at it and you get:

{ "error": "invalid_client", "error_description": "empty client assertion" }

That reads like a missing value. It means mismatched method: ZITADEL expected a signed JWT assertion and the library sent a client secret. The error names what ZITADEL wanted, never what you configured, so it points away from the actual fix.

Check what the library does before choosing. Auth.js's @auth/core/providers/zitadel is a plain OIDC provider with no token_endpoint_auth_method override and no assertion support, so it can only ever satisfy a secret-based method.

Redirect URIs

Exact string match, no trailing slash. Register every environment you use. The Development Mode toggle in Redirect Settings is what permits non-HTTPS localhost; turn it off in production.

In a BFF the redirect URI points at your API's callback, not the frontend, because the API performs the code exchange. People reflexively point it at the frontend.

Additional Origins

A CORS allowlist. ZITADEL already permits the origins of registered redirect URIs, so this is only for origins that call ZITADEL from a browser without being redirect targets. With BFF, leave it empty: the browser only follows top-level redirects, which CORS does not govern, and the token exchange is server to server. Scheme, host, optional port. No path, no wildcards.

Token Settings, which are all wrong by default

A fresh application does not emit what an application needs. Every one of these presents as a bug in your code.

SettingDefaultConsequenceTerraform attribute
Auth Token TypeBearerOpaque access token, no local verification possibleaccess_token_type = "OIDC_TOKEN_TYPE_JWT"
Include user's profile info in the ID TokenoffEmpty user object in your sessionid_token_userinfo_assertion = true
Add user roles to the access tokenoffNo roles claim for your permission layeraccess_token_role_assertion = true
User roles inside ID TokenoffSame, in the ID tokenid_token_role_assertion = true

Roles additionally require Assert Roles on Authentication on the project, and roles that actually exist and are assigned. An empty roles claim after ticking every box is expected, not a regression.

The console saves per section. Changing a dropdown and saving elsewhere on the page silently does nothing. If the application's Changed timestamp still equals Created, nothing was persisted.

Lifetimes

The default access token lifetime is 12 hours, which is the weak point of the whole JWT strategy: a revoked user keeps working for half a day because nothing checks back.

Instance Settings, OIDC Token Lifetimes:

  • Access token: 5 to 15 minutes. Short, because nothing revokes it.
  • Refresh token idle expiry: hours for web, 30 to 90 days for mobile.
  • Refresh token absolute expiry: 6 to 12 months, forcing real re-authentication.

Then enable the Refresh Token grant and request offline_access, or you get no refresh token and the session dies with the access token.

Audience is project-wide

Verified on a live token issued to a mobile application:

"aud": ["<web app client id>",
        "<mobile app client id>",
        "<project id>"]

Every application in the project appears, plus the project itself.

Validate against the project ID. One line, and every client type you add later works unchanged. Hardcoding a single client ID means the next platform fails validation for no obvious reason.

The corollary matters more: applications in one project implicitly trust each other's tokens. If a token must be accepted by the mobile API and rejected by an internal admin API, those applications belong in separate projects. Project is the trust boundary; application is not.

BFF or tokens in the browser

Default to BFF for web. The backend runs the OIDC flow, holds tokens server side, and gives the browser an httpOnly SameSite=Lax session cookie. The token never reaches JavaScript, so an XSS cannot exfiltrate a credential it can replay later or elsewhere. Current IETF guidance for browser apps has moved this way.

Use public-client PKCE where you genuinely cannot avoid it: mobile, desktop, CLI, or a frontend you do not control.

Where the frontend can live

The line that matters is same-site, not same-origin.

SetupWorksCost
Same origin (client.com, API under /api)BestNothing
Different subdomains (app. and api.client.com)FineCORS plus credentials: 'include'
Different registrable domainsFragileSameSite=None; Safari ITP already blocks these

Subdomains of one registrable domain are same-site, so a SameSite=Lax cookie is sent between them. CORS needs allow_credentials=True, which is incompatible with allow_origins=["*"].

Genuinely different domains makes the cookie third-party. It works in development and fails for a fraction of real users. If a client insists on that split, use SPA plus PKCE rather than fighting the cookie.

Verifying tokens

Cache the JWKS, verify locally, never call ZITADEL on the request path.

Python

from fastapi import Depends, HTTPException
from fastapi.security import HTTPBearer
import jwt
from jwt import PyJWKClient

jwks = PyJWKClient(f"{ISSUER}/oauth/v2/keys")

async def principal(creds=Depends(HTTPBearer())) -> Principal:
    try:
        key = jwks.get_signing_key_from_jwt(creds.credentials).key
        claims = jwt.decode(
            creds.credentials, key,
            algorithms=["RS256"],
            issuer=ISSUER,
            audience=PROJECT_ID,        # project, NOT a client id
        )
    except jwt.PyJWTError:
        raise HTTPException(401, "invalid token")
    return Principal(subject=claims["sub"], claims=claims)

TypeScript

import { createRemoteJWKSet, jwtVerify } from 'jose'

const jwks = createRemoteJWKSet(new URL(`${ISSUER}/oauth/v2/keys`))

export async function principal(token: string) {
  const { payload } = await jwtVerify(token, jwks, {
    issuer: ISSUER,
    audience: PROJECT_ID,
  })
  return { subject: payload.sub!, claims: payload }
}

Measured on a live instance: 0.449 ms with the key cache warm.

Support both entry points

With BFF the browser sends a cookie, not a bearer token. Build both resolvers from the start: cookie session for the browser, bearer JWT for mobile, CLI and service to service. Both produce the same Principal, so it is one extra resolver rather than a second auth system.

The access token does not carry profile claims

Verified: a ZITADEL JWT access token contains sub, aud, iss, exp, nbf, jti and the resourceowner claims. It does not contain email, name or preferred_username, even with the ID token assertions enabled. Those settings affect the ID token, which your API never sees.

So an API holding only an access token knows who the user is and nothing else.

Consequence for JIT provisioning: on first sight of an unknown sub, make one call to userinfo or introspect to get email and name, insert the row, and never call again for that user. Per new user, not per request, which is exactly where a remote call is worth paying for.

app_user (
  id               uuid primary key,
  external_subject text unique not null,   -- ZITADEL `sub`
  email            text not null,
  display_name     text,
  created_at       timestamptz not null default now()
)

Two things to handle:

  • The concurrent-first-request race. Two requests for a new user arrive

together and both insert. Use an upsert on the unique constraint, never check-then-insert.

  • Claims are a snapshot. Refresh the local row when the token disagrees rather

than treating it as immutable.

Introspection

Introspection is not disabled by JWT. Setting Auth Token Type to JWT gave you the option of local verification; it took nothing away.

The two answer different questions:

Local verificationIntrospection
AsksWas this genuinely issued and not expired?Is this still valid right now?
Catches revocationNo, only expiryYes
Cost~0.4 ms, offlineOne round trip
If ZITADEL is downYour API keeps servingEvery request fails
async def introspect(token: str) -> dict:
    r = await http.post(
        f"{ISSUER}/oauth/v2/introspect",
        data={"token": token},
        auth=(RS_CLIENT_ID, RS_CLIENT_SECRET),   # or a JWT assertion
    )
    return r.json()          # {"active": true, "sub": ..., "email": ...}

The caller is the resource server, not the client, and it must authenticate: otherwise anyone could probe stolen tokens to see whether they still work. Register your API as its own API application in the project.

Two findings worth knowing:

  • Introspection returns more than the JWT does. The response includes

username, name, email, email_verified, locale, amr and auth_time. That makes it a valid answer to the JIT provisioning problem above.

  • Measured cost: 97 ms across the public internet, which is network-dominated.

Co-located it is single-digit milliseconds. Latency was never the real objection.

Where to draw the line

Verify locally by default. Introspect every time, uncached, on operations where a stale token is dangerous: moving money, changing roles, deleting an account, admin impersonation. Those routes are already doing far slower work, so the round trip is rounding error.

Do not put a TTL cache in front of introspection. If you can tolerate 30 seconds of staleness you already had that for free from a short-lived JWT, without the round trip or the credentials. Request-scoped memoization is different and fine: resolving once when several internal services handle one logical operation is deduplication, not stale trust.

The real objection to introspection everywhere is not latency. It is that your entire request volume lands on ZITADEL's Postgres, the one component that scales vertically only.

Provisioning

There is no official ZITADEL management CLI. The zitadel binary is an operator CLI (init, setup, start) that runs the server and does not manage what is inside it.

Use the official Terraform provider. Every console control maps to an attribute:

resource "zitadel_application_oidc" "app" {
  # ...
  access_token_type           = "OIDC_TOKEN_TYPE_JWT"
  access_token_role_assertion = true
  id_token_role_assertion     = true
  id_token_userinfo_assertion = true
}

Seeing that mapping is the argument itself: the console is a fine place to learn and a poor place to configure a client instance, because none of it is reproducible and every default is wrong.

Bootstrap uses the same FIRSTINSTANCE block that creates the admin: it can provision a machine user and emit a PAT at first boot for Terraform to authenticate with.

Remember loadbalancer.server.scheme=h2c on the proxy before running Terraform. The provider uses native gRPC over HTTP/2, unlike the console's gRPC-Web.

What never to build

An endpoint that accepts a username and password and forwards them to ZITADEL. That is the resource-owner-password grant, deprecated in OAuth 2.1 and supported only for legacy migration. It puts credentials through your code and breaks MFA, social login and enterprise SSO at once.