Appearance
Platform integration checklist
Use this page as the implementation contract for any IESF platform BFF. Do not integrate with ZITADEL from the platform.
Prerequisites
- Central administrators register your platform via IESF Card Platform Admin (
/admin/platforms) orPOST /v1/platforms(platforms:admin). See Admin operations. - You receive
clientIdandclientSecretonce (create or rotate). Store them in a secret manager; never put them in browser storage or public config. - Registration includes:
callbackUrl— HTTPS (or local HTTP) endpoint on your BFF that accepts?code=allowedReturnUrls— one or more URL roots for final redirectsdefaultOnboardingMode—none,platform, orcentralcentralOnboardingEnabled— must betrueif default mode iscentral
- Your platform is
active. - Onboarding fields for your
platformKeyare configured in Card Onboarding Admin (/admin/onboarding) when the platform needs a schema.
Checklist
1. Start authentication
Redirect the browser to Central:
http
GET {CENTRAL_BASE}/v1/auth/start?platformKey={YOUR_KEY}&returnTo={PATH_OR_URL}Rules:
platformKeymust match the registry exactlyreturnTomust be allowlisted (absolute URL or root-relative path)- Optional
onboardingModemust match the registered default if supplied - Do not send users to ZITADEL authorize URLs yourself
2. Own the callback
Implement the registered callbackUrl. Central redirects:
http
GET {callbackUrl}?code=handoff_…Reject callbacks when:
codeis missingcodedoes not start withhandoff_
3. Exchange server-to-server
From the BFF only:
http
POST {CENTRAL_BASE}/v1/auth/exchange
Authorization: Basic {base64(clientId:clientSecret)}
Content-Type: application/json
Accept: application/json
{"code":"handoff_…"}Validate the JSON:
platform.keyequals your platform keysession.gatewayTokenis presentsession.expiresAtis in the future
4. Create your local session
Build a platform-owned session from the exchange body. Suggested minimum:
| Field | Source |
|---|---|
| Local user id | user.id |
| Stable subject | user.sub |
| Platform | platform.key |
| Onboarding snapshot | onboarding.* |
| Permissions | permissions |
| Gateway credential | session.gatewayToken + session.expiresAt |
| Post-login destination | returnTo |
Requirements:
- Persist
gatewayTokenonly server-side or in an HttpOnly cookie you control - Never put
gatewayToken, Basic credentials, or the handoff code into browser JavaScript, localStorage, or URLs after the callback - Redirect the browser to
returnToonly after the session exists (or to your onboarding UI — see below)
5. Onboarding branch
Inspect onboarding.status after exchange:
| Mode | Incomplete behavior |
|---|---|
none | Proceed; status is informational |
platform | Send the user to your onboarding UI; call Central onboarding APIs with the gateway cookie |
central | Incomplete users never reach you until Account Center + /v1/auth/resume complete; if you somehow see incomplete, treat as error |
See Onboarding.
6. Calling Central user APIs with the gateway token
User APIs accept the opaque gateway session as the configured cookie (AUTH_SESSION_COOKIE_NAME, default iesf_auth_session), not as Authorization: Bearer.
http
Cookie: iesf_auth_session={gatewayToken}For cookie-authenticated writes (PUT/POST/PATCH/DELETE), Central also requires Origin to be listed in CORS_ORIGINS. Typical pattern: browser calls from an allowlisted platform origin with credentials: 'include', after your BFF has set an HttpOnly cookie that the browser sends to Central — or your Account Center origin is allowlisted and hosts that UI.
Authorization: Bearer on these routes means a ZITADEL access token. Platforms do not receive those tokens from handoff exchange.
7. Hard no’s
- Do not create a ZITADEL application for the platform
- Do not expect ID / access / refresh tokens from Central
- Do not reuse a handoff code
- Do not trust client-supplied completion flags
- Do not accept
returnToor callback URLs from the query string as overrides of the registry
Minimal BFF pseudocode
ts
// GET /api/auth/callback?code=handoff_…
async function handleCallback(code: string) {
if (!code.startsWith('handoff_')) throw new Error('invalid_callback');
const response = await fetch(`${CENTRAL}/v1/auth/exchange`, {
method: 'POST',
headers: {
Authorization: `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64')}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ code }),
});
if (!response.ok) throw new Error('handoff_exchange_failed');
const exchange = await response.json();
if (exchange.platform.key !== PLATFORM_KEY || !exchange.session.gatewayToken) {
throw new Error('invalid_handoff');
}
await savePlatformSession({
userId: exchange.user.id,
sub: exchange.user.sub,
gatewayToken: exchange.session.gatewayToken,
expiresAt: exchange.session.expiresAt,
onboarding: exchange.onboarding,
permissions: exchange.permissions,
});
if (ONBOARDING_MODE === 'platform' && exchange.onboarding.status !== 'COMPLETE') {
return redirect(PLATFORM_ONBOARDING_PATH);
}
return redirect(exchange.returnTo);
}