Appearance
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-redirectprompt=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 pickerThis 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
web/src/auth/login.ts—redirectConsoleLoginandredirectExternalToZitadel- 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
- Store routing context:
web/src/pages/AuthBouncePage.tsx— New page (minimal, single-purpose)- Reads
pending_login_contextfrom sessionStorage and removes it - If
flow === 'console': callsredirectConsoleLogin(loginOpts)(generates fresh PKCE) - If
flow === 'external': callsredirectExternalToZitadel(externalParams, loginOpts) - If no context found: redirects to
/as fallback
- Reads
web/src/routes/index.tsx— Register/auth-bounceroute
Backend / Infrastructure
scripts/bootstrap/main.go— Register/auth-bounceas a post-logout redirect URI- Change
postLogoutRedirectUrisfrom[consoleRoot]to[consoleRoot, consoleRoot + "/auth-bounce"]
- Change
internal/zitadel/client.go— Same change inCreateConsoleOIDCApp- Change
postLogoutRedirectUrisfrom[base]to[base, urlutil.Join(base, "/auth-bounce")]
- Change
Existing Zitadel instances — Add
{consoleOrigin}/auth-bounceto 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-bounceonly 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-bounceURI is only used by the session-clearing login flow
Verification
- Log in as one user (e.g., zitadel-admin)
- Log out from Console
- Go to
/login page, enter a different email - Should NOT see Zitadel account picker — should go straight to password/IdP login
- Test external flow: visit
/with external OIDC params, enter email, verify no account picker - Visit
/auth-bouncedirectly with no context — should redirect to/

