Appearance
Authentication Flow
This document covers how authentication works across the Exto platform — Console, exto-web, and Zitadel.
Overview
Console acts as the centralized login gateway for the entire platform. Both Console's own UI and exto-web (the instance tenant app) route through Console's login page for authentication. Zitadel is the identity provider that handles the actual credential verification.
| Component | Role |
|---|---|
| Console | Login gateway — org resolution, IdP selection, branding |
| Zitadel | Identity provider — credential verification, SSO, MFA, token issuer |
| exto-web | Tenant application — initiates login via Console, exchanges tokens directly with Zitadel |
| exto-go | exto-web backend — validates JWT, manages sessions |
Key Concepts
Separate OIDC Apps
Each application has its own OIDC app registered in Zitadel:
| OIDC App | Client ID Source | Redirect URIs |
|---|---|---|
| Console Portal | VITE_CONSOLE_PORTAL_CLIENT_ID env var | console.example.com/callback |
| Instance (per instance) | Created by Console via CreateInstanceOIDCApp | instance.example.com/auth/callback and instance.example.com/3/auth/callback |
Note: Each instance OIDC app registers two redirect URIs —
/auth/callback(standard) and/3/auth/callback(v3 app route). Both are registered in Zitadel when the instance is created. Console's login resolver accepts either path during client validation.
Zitadel routes the callback to the correct app based on the client_id in the authorize request.
Orgs and OIDC Apps Are Independent
- Org = where the user lives (login policies, IdPs, password rules)
- OIDC App = which application is requesting auth (redirect URIs, client credentials)
A user in any org can authenticate against any OIDC app. The org scope (urn:zitadel:iam:org:id:{orgId}) tells Zitadel which org's policies to apply.
Flow 1: Console Login (Internal Admin or Customer accessing Console)
Used when a user navigates directly to Console to manage customers, billing, settings, etc.
User → Console login page (/)
│
├─ Enter email
├─ POST /api/v1/login/resolve-org → { type, orgs, authMethods }
│ ├─ "internal" → redirect to Zitadel (no org scope)
│ └─ "customer" → pick org → pick auth method (SSO / password)
│
├─ Console generates PKCE (code_verifier + code_challenge)
├─ Stores code_verifier, state, and nonce in sessionStorage
│
└─ Redirect to Zitadel:
GET {issuer}/oauth/v2/authorize
?client_id={CONSOLE_PORTAL_CLIENT_ID}
&redirect_uri={console}/callback
&scope=openid profile email urn:zitadel:iam:user:resourceowner
[urn:zitadel:iam:org:id:{orgId}]
[urn:zitadel:iam:org:idp:id:{idpId}]
&code_challenge={challenge}
&code_challenge_method=S256
&state={state}
&nonce={nonce}
&login_hint={email}After Zitadel authenticates the user:
Zitadel → Console /callback?code={code}&state={state}
│
├─ Validate state matches sessionStorage
├─ Exchange code for tokens:
│ POST {issuer}/oauth/v2/token
│ grant_type=authorization_code
│ code={code}
│ code_verifier={verifier}
│ client_id={CONSOLE_PORTAL_CLIENT_ID}
│ redirect_uri={console}/callback
│
├─ Validate nonce in id_token matches sessionStorage
├─ Decode JWT → extract user info (sub, email, org, role)
├─ GET /api/v1/auth/me → DB-resolved role + customerId
└─ Navigate to dashboard or customer portalFlow 2: exto-web Login via Console Gateway
Used when a tenant user logs into exto-web. Console acts as a transparent login gateway — it never touches the tokens.
Step 1: exto-web redirects to Console
User clicks "SSO" on exto-web login page
│
├─ exto-web generates PKCE (code_verifier + code_challenge)
├─ Stores code_verifier, state, and nonce in sessionStorage
│
└─ Redirect to Console login page:
GET {consoleLoginUrl}/
?client_id={INSTANCE_OIDC_CLIENT_ID}
&redirect_uri={exto-web}/auth/callback
&scope=openid profile email offline_access
&code_challenge={challenge}
&code_challenge_method=S256
&state={state}
&nonce={nonce}Note: exto-web sends standard OIDC scopes only. It must NOT send Zitadel-specific scopes (
urn:zitadel:iam:*). Console owns org/IdP scope resolution and will strip any such scopes from external requests (see Scope Sanitization).
Step 2: Console validates and shows login UI
Console login page loads
│
├─ Detects external OIDC params (client_id, redirect_uri, etc.)
├─ Validates via: GET /api/v1/login/resolve-client
│ ?client_id={INSTANCE_OIDC_CLIENT_ID}
│ &redirect_uri={exto-web}/auth/callback
│ → Checks client_id exists in instance_connections
│ → Checks redirect_uri matches registered URI
│
├─ User enters email
├─ POST /api/v1/login/resolve-org → org + auth methods
└─ User selects org / auth methodStep 3: Console sanitizes scopes and redirects to Zitadel
Console builds Zitadel authorize URL:
│
├─ Sanitize external scope: strip all urn:zitadel:iam:* scopes from exto-web's request
├─ Append Console-resolved scopes:
│ urn:zitadel:iam:org:id:{orgId} ← from /resolve-org
│ urn:zitadel:iam:org:idp:id:{idpId} ← from /resolve-org (if SSO)
│
└─ Redirect to Zitadel:
GET {issuer}/oauth/v2/authorize
?response_type=code
&client_id={INSTANCE_OIDC_CLIENT_ID} ← exto-web's client ID
&redirect_uri={exto-web}/auth/callback ← exto-web's callback
&scope={sanitized scopes} ← see Scope Sanitization below
[urn:zitadel:iam:org:id:{orgId}]
[urn:zitadel:iam:org:idp:id:{idpId}]
&code_challenge={challenge} ← exto-web's PKCE challenge
&code_challenge_method=S256
&state={state} ← exto-web's state
&nonce={nonce} ← exto-web's nonce (passed through)
&login_hint={email}Step 4: Zitadel redirects directly to exto-web
Zitadel authenticates user → redirects to exto-web:
GET {exto-web}/auth/callback?code={code}&state={state}
Console is NOT involved from this point forward.Step 5: exto-web exchanges code for tokens
exto-web /auth/callback
│
├─ Validate state matches sessionStorage
├─ Exchange code directly with Zitadel:
│ POST {issuer}/oauth/v2/token
│ grant_type=authorization_code
│ code={code}
│ code_verifier={verifier} ← from exto-web's sessionStorage
│ client_id={INSTANCE_OIDC_CLIENT_ID}
│ redirect_uri={exto-web}/auth/callback
│
├─ Validate nonce in id_token matches sessionStorage
├─ Store tokens in sessionStorage
├─ POST /auth/zitadel/callback → exto-go validates JWT, creates session
└─ Navigate to /projectsFlow Comparison
Console login: Console → Zitadel → Console/callback → Console exchanges code → Console dashboard
exto-web login: exto-web → Console (UI only) → Zitadel → exto-web/callback → exto-web exchanges codeKey difference: In the exto-web flow, Console only provides the login UI. It passes exto-web's OIDC params through to Zitadel. The token exchange happens directly between exto-web and Zitadel.
Org Resolution
The /api/v1/login/resolve-org endpoint determines the user's org and available auth methods.
Request:
json
POST /api/v1/login/resolve-org
{ "email": "user@company.com" }Response types:
| Type | Meaning | Next step |
|---|---|---|
internal | User is an internal Exto admin | Console flow: redirect to Zitadel (no org scope). exto-web flow: show generic error (admins don't use exto-web) |
customer | User belongs to one or more customer orgs | Pick org → pick auth method → redirect |
not_found | No account for this email | Show generic error (same message as internal in exto-web flow to prevent email enumeration) |
Auth methods per org:
| Method | Behavior |
|---|---|
password | Redirect to Zitadel with login_hint (Zitadel shows password form) |
sso | Redirect to Zitadel with idpId scope (auto-redirect to IdP) |
Client Validation
The /api/v1/login/resolve-client endpoint validates external OIDC params in the exto-web flow.
Request:
GET /api/v1/login/resolve-client?client_id={id}&redirect_uri={uri}Validation:
- Look up
client_idininstance_connectionscollection - Reject if
oidcRedirectUriis empty (legacy records created before this field was introduced) - Verify
redirect_urimatches the registeredoidcRedirectUri - Return
{ valid: true }or{ valid: false }
This prevents open redirect attacks — Console will not construct a Zitadel authorize URL with an unregistered or missing redirect URI.
Scope Sanitization
In the exto-web flow, Console owns all Zitadel-specific scopes. External callers (exto-web) send standard OIDC scopes (openid, profile, email, offline_access), and Console appends the org/IdP scopes it resolved via /resolve-org.
Why? Org and IdP scopes determine which org's login policies apply and whether to auto-redirect to an external IdP. Trusting these from an external caller would allow scope injection — e.g., forcing a different org's policies or bypassing IdP selection.
How it works:
- Console receives exto-web's scope string (e.g.,
openid profile email offline_access) - Console strips any scope starting with
urn:zitadel:iam:— these are never trusted from external callers - Console appends its own resolved scopes:
urn:zitadel:iam:org:id:{orgId}— resolved via/resolve-orgurn:zitadel:iam:org:idp:id:{idpId}— resolved via/resolve-org(only for SSO methods)
External scope: "openid profile email offline_access urn:zitadel:iam:org:id:fake123"
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
stripped by Console
Sanitized scope: "openid profile email offline_access"
Final scope: "openid profile email offline_access urn:zitadel:iam:org:id:{resolved_orgId}"Console's own login flow (Flow 1) is not affected — it builds scopes entirely from
buildScope()using its own OIDC config.
IdP Auto-Redirect
When a customer org has a single SSO provider and the auth rule is SSO-only, Console passes the IdP scope:
scope=... urn:zitadel:iam:org:idp:id:{idpId}This causes Zitadel to skip its own login page entirely and redirect directly to the external IdP (Google, Azure, etc.). The user never sees Zitadel's UI.
Configuration
Console (web/.env)
env
VITE_ZITADEL_ISSUER=https://auth.exto360.com
VITE_CONSOLE_PORTAL_CLIENT_ID=<console-oidc-client-id>
VITE_REDIRECT_URI=https://console.exto360.com/callback
VITE_ZITADEL_ADMIN_ORG_ID=<admin-org-id>exto-go (.env)
env
ZITADEL_ISSUER=https://auth.exto360.com
ZITADEL_CLIENT_ID=<instance-oidc-client-id>
CONSOLE_LOGIN_URL=https://console.exto360.comThe CONSOLE_LOGIN_URL is returned via the /auth/config endpoint so exto-web knows where to redirect for login.
Instance Connection (stored in PostgreSQL by Console)
Each instance has an instance_connections record with:
| Field | Purpose |
|---|---|
oidcClientId | Instance's Zitadel OIDC client ID |
oidcRedirectUri | Registered redirect URI for validation |
zitadelAppId | Zitadel app ID (for rotation/deletion) |
Frontend Architecture (Console)
The Console frontend separates the two login flows into independent components and functions to avoid entanglement.
Page Components
| Component | File | Responsibility |
|---|---|---|
LoginLanding | pages/LoginLanding.tsx | Thin dispatcher — parses URL for external OIDC params, renders ConsoleLoginPage or ExternalLoginPage |
ConsoleLoginPage | pages/login/ConsoleLoginPage.tsx | Console's own login flow — resolves org, calls redirectConsoleLogin() |
ExternalLoginPage | pages/login/ExternalLoginPage.tsx | exto-web gateway flow — validates client, resolves org, calls redirectExternalToZitadel() |
LoginShell | pages/login/LoginShell.tsx | Shared UI building blocks (LoginLayout, EmailStep, OrgPickerStep, IdpPickerStep, ErrorBanner) |
CallbackPage | auth/CallbackPage.tsx | Console's own OIDC callback — exchanges code, validates nonce, hydrates session |
Auth Functions
| Function | File | Flow |
|---|---|---|
redirectConsoleLogin(opts?) | auth/login.ts | Console's own flow — generates PKCE + nonce, stores in sessionStorage, redirects to Zitadel |
redirectExternalToZitadel(externalParams, opts?) | auth/login.ts | External flow — sanitizes scopes, passes through external PKCE/state/nonce, redirects to Zitadel |
sanitizeExternalScope(rawScope, orgId?, idpId?) | auth/login.ts | Strips urn:zitadel:iam:* scopes, appends Console-resolved org/IdP scopes |
silentReauth() | auth/login.ts | Console-only — silent re-auth via prompt=none |
Flow Isolation
The two flows are fully separated:
- Console login:
ConsoleLoginPage→redirectConsoleLogin()→ Zitadel →CallbackPage - External login:
ExternalLoginPage→redirectExternalToZitadel()→ Zitadel → exto-web callback
They share only the visual UI components from LoginShell (email form, org picker, IdP picker). There is no if (externalParams) branching in the redirect or page logic — each flow has its own dedicated function and component.
Tests
Unit tests are in web/src/auth/__tests__/ and cover:
pkce.test.ts— PKCE verifier/challenge generation, auth URL constructionjwt.test.ts— JWT payload decoding edge caseslogin.test.ts— scope sanitization (injection prevention), external redirect param forwarding, nonce/login_hint passthrough
Run with npm test from the web/ directory.
Security Considerations
- PKCE (S256) is used for all flows — no client secrets on the frontend.
- Nonce is included in the authorize request and validated in the
id_tokenon callback. This prevents token replay attacks. Each app (Console, exto-web) generates and validates its own nonce. Console passes through exto-web's nonce to Zitadel without storing it. - Console never touches tokens in the exto-web flow. It only provides the login UI and constructs the Zitadel authorize URL.
- Redirect URI validation prevents open redirect attacks. Console validates the
redirect_uriagainst the registered URI ininstance_connectionsbefore proceeding. Connections with an emptyoidcRedirectUriare rejected. - State parameter prevents CSRF. Generated by the initiating app (Console or exto-web), validated on callback.
- Org scope ensures Zitadel applies the correct org's login policies (MFA, password rules, allowed IdPs).
- Scope sanitization prevents scope injection. Console strips all
urn:zitadel:iam:*scopes from external requests and only adds the scopes it resolved itself via/resolve-org. This ensures an external caller cannot force a different org's policies or bypass IdP selection. - Anti-enumeration: The
/resolve-orgendpoint returnsinternalfor real admins andnot_foundfor unknown emails. The frontend shows the same generic error for both in the exto-web flow, and fornot_foundin Console's own flow. This prevents attackers from determining whether an email has an account.

