Skip to content

Auth Bounce: Clearing Stale Zitadel Sessions Before Login

Problem

When Console redirects to Zitadel's authorize endpoint with login_hint, Zitadel shows an account picker if a stale browser session exists for a different user — making Console's email collection step feel redundant.

OIDC prompt values don't solve this cleanly:

  • No prompt + login_hint → account picker when a different session exists
  • prompt=login → forces re-auth but Zitadel shows a "You are signed in" confirmation page that doesn't auto-redirect
  • prompt=select_account → always shows the account picker

Solution

A two-step redirect via a dedicated /auth-bounce route that clears all Zitadel sessions before initiating the authorize flow.

Flow

User enters email on Console login page
  → Console resolves org/IdP via /api/v1/login/resolve-org
  → Stores routing context (no secrets) in sessionStorage
  → Redirects to Zitadel /oidc/v1/end_session?post_logout_redirect_uri=.../auth-bounce
  → Zitadel clears all browser sessions, redirects to /auth-bounce
  → AuthBouncePage reads context from sessionStorage, removes it
  → Calls redirectConsoleLogin() or redirectExternalToZitadel() with fresh PKCE
  → Zitadel shows clean login form with login_hint pre-filled — no account picker

This works for both Console's own login flow and the external app gateway flow.

What gets stored in sessionStorage

Key: pending_login_context

ts
// Console flow
{ flow: 'console', loginOpts: { orgId?, loginHint?, idpId? } }

// External flow
{ flow: 'external', loginOpts: { orgId?, loginHint?, idpId? }, externalParams: ExternalOIDCParams }

Security: No PKCE verifier, no tokens, no authorize URLs are stored — only the routing context that was already visible in the URL query params or form input. PKCE is regenerated fresh on /auth-bounce.

Files to modify

Frontend

  1. web/src/auth/login.tsredirectConsoleLogin and redirectExternalToZitadel

    • Store routing context: sessionStorage.setItem('pending_login_context', JSON.stringify(...))
    • Redirect to Zitadel's end_session: {issuer}/oidc/v1/end_session?post_logout_redirect_uri={consoleOrigin}/auth-bounce
  2. web/src/pages/AuthBouncePage.tsx — New page (minimal, single-purpose)

    • Reads pending_login_context from sessionStorage and removes it
    • If flow === 'console': calls redirectConsoleLogin(loginOpts) (generates fresh PKCE)
    • If flow === 'external': calls redirectExternalToZitadel(externalParams, loginOpts)
    • If no context found: redirects to / as fallback
  3. web/src/routes/index.tsx — Register /auth-bounce route

Backend / Infrastructure

  1. scripts/bootstrap/main.go — Register /auth-bounce as a post-logout redirect URI

    • Change postLogoutRedirectUris from [consoleRoot] to [consoleRoot, consoleRoot + "/auth-bounce"]
  2. internal/zitadel/client.go — Same change in CreateConsoleOIDCApp

    • Change postLogoutRedirectUris from [base] to [base, urlutil.Join(base, "/auth-bounce")]
  3. Existing Zitadel instances — Add {consoleOrigin}/auth-bounce to the Console Portal OIDC app's post-logout redirect URIs (or re-run bootstrap for dev)

Why /auth-bounce instead of redirecting back to /

  • Single responsibility: /auth-bounce only handles the session-clearing resume — no route guards, no auth checks, no login page re-rendering
  • Debuggability: Easy to see in browser network tab and logs when the bounce happens
  • No interference: Normal logout continues to redirect to / as before; the /auth-bounce URI is only used by the session-clearing login flow

Verification

  1. Log in as one user (e.g., zitadel-admin)
  2. Log out from Console
  3. Go to / login page, enter a different email
  4. Should NOT see Zitadel account picker — should go straight to password/IdP login
  5. Test external flow: visit / with external OIDC params, enter email, verify no account picker
  6. Visit /auth-bounce directly with no context — should redirect to /