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.
| Surface | Who does it | With what |
|---|---|---|
| Login: browser or device to ZITADEL | Frontend, or your BFF | OIDC Authorization Code + PKCE |
| Token validation: your API verifies the JWT | Backend | JWKS, cached, verified locally |
| Provisioning: organizations, projects, roles | Infrastructure as code | Terraform 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:
| Client | Method | Why |
|---|---|---|
| Backend you wrote (BFF) | Private Key JWT | Strongest, and you control the token request |
| A library that only knows client secrets | Basic | Auth.js, most generic OIDC clients |
| Browser SPA | None (PKCE) | Cannot hold a secret |
| Mobile or CLI | None (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.
| Setting | Default | Consequence | Terraform attribute |
|---|---|---|---|
| Auth Token Type | Bearer | Opaque access token, no local verification possible | access_token_type = "OIDC_TOKEN_TYPE_JWT" |
| Include user's profile info in the ID Token | off | Empty user object in your session | id_token_userinfo_assertion = true |
| Add user roles to the access token | off | No roles claim for your permission layer | access_token_role_assertion = true |
| User roles inside ID Token | off | Same, in the ID token | id_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.
| Setup | Works | Cost |
|---|---|---|
Same origin (client.com, API under /api) | Best | Nothing |
Different subdomains (app. and api.client.com) | Fine | CORS plus credentials: 'include' |
| Different registrable domains | Fragile | SameSite=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 verification | Introspection | |
|---|---|---|
| Asks | Was this genuinely issued and not expired? | Is this still valid right now? |
| Catches revocation | No, only expiry | Yes |
| Cost | ~0.4 ms, offline | One round trip |
| If ZITADEL is down | Your API keeps serving | Every 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.