Skip to content

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.

ComponentRole
ConsoleLogin gateway — org resolution, IdP selection, branding
ZitadelIdentity provider — credential verification, SSO, MFA, token issuer
exto-webTenant application — initiates login via Console, exchanges tokens directly with Zitadel
exto-goexto-web backend — validates JWT, manages sessions

Key Concepts

Separate OIDC Apps

Each application has its own OIDC app registered in Zitadel:

OIDC AppClient ID SourceRedirect URIs
Console PortalVITE_CONSOLE_PORTAL_CLIENT_ID env varconsole.example.com/callback
Instance (per instance)Created by Console via CreateInstanceOIDCAppinstance.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 portal

Flow 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 method

Step 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 /projects

Flow 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 code

Key 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:

TypeMeaningNext step
internalUser is an internal Exto adminConsole flow: redirect to Zitadel (no org scope). exto-web flow: show generic error (admins don't use exto-web)
customerUser belongs to one or more customer orgsPick org → pick auth method → redirect
not_foundNo account for this emailShow generic error (same message as internal in exto-web flow to prevent email enumeration)

Auth methods per org:

MethodBehavior
passwordRedirect to Zitadel with login_hint (Zitadel shows password form)
ssoRedirect 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:

  1. Look up client_id in instance_connections collection
  2. Reject if oidcRedirectUri is empty (legacy records created before this field was introduced)
  3. Verify redirect_uri matches the registered oidcRedirectUri
  4. 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:

  1. Console receives exto-web's scope string (e.g., openid profile email offline_access)
  2. Console strips any scope starting with urn:zitadel:iam: — these are never trusted from external callers
  3. Console appends its own resolved scopes:
    • urn:zitadel:iam:org:id:{orgId} — resolved via /resolve-org
    • urn: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.com

The 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:

FieldPurpose
oidcClientIdInstance's Zitadel OIDC client ID
oidcRedirectUriRegistered redirect URI for validation
zitadelAppIdZitadel 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

ComponentFileResponsibility
LoginLandingpages/LoginLanding.tsxThin dispatcher — parses URL for external OIDC params, renders ConsoleLoginPage or ExternalLoginPage
ConsoleLoginPagepages/login/ConsoleLoginPage.tsxConsole's own login flow — resolves org, calls redirectConsoleLogin()
ExternalLoginPagepages/login/ExternalLoginPage.tsxexto-web gateway flow — validates client, resolves org, calls redirectExternalToZitadel()
LoginShellpages/login/LoginShell.tsxShared UI building blocks (LoginLayout, EmailStep, OrgPickerStep, IdpPickerStep, ErrorBanner)
CallbackPageauth/CallbackPage.tsxConsole's own OIDC callback — exchanges code, validates nonce, hydrates session

Auth Functions

FunctionFileFlow
redirectConsoleLogin(opts?)auth/login.tsConsole's own flow — generates PKCE + nonce, stores in sessionStorage, redirects to Zitadel
redirectExternalToZitadel(externalParams, opts?)auth/login.tsExternal flow — sanitizes scopes, passes through external PKCE/state/nonce, redirects to Zitadel
sanitizeExternalScope(rawScope, orgId?, idpId?)auth/login.tsStrips urn:zitadel:iam:* scopes, appends Console-resolved org/IdP scopes
silentReauth()auth/login.tsConsole-only — silent re-auth via prompt=none

Flow Isolation

The two flows are fully separated:

  • Console login: ConsoleLoginPageredirectConsoleLogin() → Zitadel → CallbackPage
  • External login: ExternalLoginPageredirectExternalToZitadel() → 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 construction
  • jwt.test.ts — JWT payload decoding edge cases
  • login.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_token on 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_uri against the registered URI in instance_connections before proceeding. Connections with an empty oidcRedirectUri are 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-org endpoint returns internal for real admins and not_found for unknown emails. The frontend shows the same generic error for both in the exto-web flow, and for not_found in Console's own flow. This prevents attackers from determining whether an email has an account.