Skip to content

Platform integration checklist

Use this page as the implementation contract for any IESF platform BFF. Do not integrate with ZITADEL from the platform.

Prerequisites

  1. Central administrators register your platform via IESF Card Platform Admin (/admin/platforms) or POST /v1/platforms (platforms:admin). See Admin operations.
  2. You receive clientId and clientSecret once (create or rotate). Store them in a secret manager; never put them in browser storage or public config.
  3. Registration includes:
    • callbackUrl — HTTPS (or local HTTP) endpoint on your BFF that accepts ?code=
    • allowedReturnUrls — one or more URL roots for final redirects
    • defaultOnboardingModenone, platform, or central
    • centralOnboardingEnabled — must be true if default mode is central
  4. Your platform is active.
  5. Onboarding fields for your platformKey are 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:

  • platformKey must match the registry exactly
  • returnTo must be allowlisted (absolute URL or root-relative path)
  • Optional onboardingMode must 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:

  • code is missing
  • code does not start with handoff_

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.key equals your platform key
  • session.gatewayToken is present
  • session.expiresAt is in the future

4. Create your local session

Build a platform-owned session from the exchange body. Suggested minimum:

FieldSource
Local user iduser.id
Stable subjectuser.sub
Platformplatform.key
Onboarding snapshotonboarding.*
Permissionspermissions
Gateway credentialsession.gatewayToken + session.expiresAt
Post-login destinationreturnTo

Requirements:

  • Persist gatewayToken only 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 returnTo only after the session exists (or to your onboarding UI — see below)

5. Onboarding branch

Inspect onboarding.status after exchange:

ModeIncomplete behavior
noneProceed; status is informational
platformSend the user to your onboarding UI; call Central onboarding APIs with the gateway cookie
centralIncomplete 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 returnTo or 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);
}