Skip to main content
The ixo Auth Hub is the shared OAuth-style login surface for ixo apps. Your app redirects the user to the hub, the user signs in (WorkOS AuthKit — Google, Apple, or Enterprise SSO) and approves your app on a consent screen, then the hub redirects back with a one-time code that your app exchanges for the user’s session data (address, DID, session key, Matrix credentials). This guide covers the integrator contract you need to meet to stay compatible as the hub rolls out enforcement of PKCE, CSRF state, and the redirect URI allowlist.

Before you start

You need:
  • A calling app with a stable redirect_uri (HTTPS, loopback for local dev, or a custom scheme for native apps).
  • The Auth Hub base URL for your target network — see Networks and endpoints.
  • The ability to store a short-lived value (the PKCE code_verifier and CSRF state) between the redirect out and the callback in — typically sessionStorage for web, or the equivalent secure store on native.

What the hub expects from your app

The hub now enforces four things at every point where a one-time code can be minted (/api/auth/login, /api/auth/register/start, /api/auth/complete) so the requirements can’t be downgraded mid-flow:
  1. PKCE (S256) — send a code_challenge on the initial redirect and the matching code_verifier on exchange.
  2. CSRF state — send a random state on the initial redirect; verify it matches on callback.
  3. Registered redirect_uri — the exact URI must be registered as one of your app’s redirect URIs (loopback addresses are always allowed for local dev).
  4. Consent — the user sees a Google-style consent screen showing your app’s name and logo the first time, then again after 90 days.
All four are currently rolled out in report-only mode across every environment — non-compliant requests are still allowed, but they log a CLIENT_ADOPTION_GAP marker so operators can see who still needs to migrate. Adopt them now so the flip to enforced (REQUIRE_PKCE, REQUIRE_STATE, REQUIRE_ALLOWLIST = true, rolling devnet → testnet → mainnet) is a no-op for your users.

Step 1 — Generate PKCE and state

Generate a fresh code_verifier and state per login attempt and store them locally. Derive the code_challenge as the base64url-encoded SHA-256 of the verifier (RFC 7636, S256).
The code_verifier never leaves the browser. That’s what binds the one-time code to the app that started the flow — a phished or leaked code can’t be redeemed by anyone else.

Step 2 — Redirect the user to the hub

Send the user to /api/auth/login with your redirect_uri, state, code_challenge, and code_challenge_method=S256.
Optional query parameters:

redirect_uri scheme policy

Independent of the allowlist, every redirect_uri must pass a scheme-safety gate (enforced in all environments):
  • https:// — allowed for any host.
  • http:// — allowed only for loopback (localhost, 127.0.0.1, ::1). Plaintext HTTP to a remote host is rejected — the one-time code could be intercepted in transit.
  • Custom schemes (impactsx://…, com.example.app:/…) — allowed, so native and mobile apps keep working.
  • javascript:, data:, vbscript:, blob:, file: — always rejected.
A disallowed scheme sends the user to a styled same-origin error page (/auth-error?error=insecure_redirect_uri); the hub never bounces to an untrusted target.

Step 3 — Handle the callback

The hub redirects the user back to your redirect_uri with code and state on success, or error and error_description on failure. Verify state matches what you stored, then exchange the code.
If state doesn’t match, abort the flow. Never exchange a code you can’t tie back to a login attempt you started — that’s exactly the CSRF case state is protecting against.

Step 4 — Exchange the code

Call /api/auth/exchange with the code and the code_verifier you stashed in Step 1. The hub verifies the S256 hash and returns the user’s session data.
Codes are one-time and short-lived. A mismatched code_verifier returns an error and marks the code as consumed.

Redirect URI registration

The hub matches the incoming redirect_uri exactly against a per-environment allowlist of registered clients (D1-backed, one redirect URI → one client). There’s no client_id parameter — the URI is the identity. Registration gives your app:
  • A name and logo on the consent screen (unknown web clients show the host; native apps with custom schemes show “an app on your device”).
  • Remembered consent for 90 days per (user, client) — only registered clients get this.
  • Compatibility once REQUIRE_ALLOWLIST=true flips per environment.
To register (or update) your app in an environment, open a PR to ixoworld/ixo-auth-hub adding an entry to the CLIENTS map in sync-clients.mjs with:
  • id — a short stable slug (also the consent key).
  • name — the human name shown on the consent screen.
  • logo_url — optional; falls back to a coloured initial-avatar.
  • homepage — optional.
  • One or more exact redirect_uri values.
Operators run node sync-clients.mjs <env> to upsert the registry (idempotent, per environment). Changes take effect within about 60 seconds — the worker caches the registry in-memory per isolate.
Loopback addresses (http://localhost:*, 127.0.0.1, ::1) are always accepted for local development regardless of the allowlist and do not need to be registered. Don’t add loopback URIs to remote environments.
Once REQUIRE_ALLOWLIST=true is set for an env, an unregistered redirect_uri:
  • At /login → renders the styled /auth-error?error=unregistered_client&redirect_uri=… page (the hub does not bounce to the invalid target).
  • At /register/start400.
  • At /complete403.
The returning-user path (/callback, /verify-email) is unaffected. Before any code is handed over, the auth-hub SPA shows a Google-style consent screen with the calling app’s name and logo:
  • Continue — consent is recorded for 90 days, the code’s clock is reset, and the user is redirected to redirect_uri?code=…&state=….
  • Cancel — the code is invalidated server-side and the user is returned with redirect_uri?error=access_denied&state=… (OAuth 2.0 §4.1.2.1).
On later logins within 90 days the screen is skipped automatically for the same (user, client) pair. Only registered clients are remembered; unregistered clients see the screen every time.

Rolling out enforcement — the REQUIRE_* flags

REQUIRE_PKCE, REQUIRE_STATE, and REQUIRE_ALLOWLIST are all currently "false" in every environment (devnet, testnet, mainnet) — the hub is in report-only mode. Non-compliant logins are allowed but logged with a standardised marker:
Operators watch these markers per environment. Once a feature shows no gaps for the clients that matter, the flag is flipped to "true" for that env (rolled devnet → testnet → mainnet). Enforcement is re-asserted at /login, /register/start, and /complete so it can’t be downgraded mid-flow. Direct auth-hub logins that don’t pass a redirect_uri are never affected. Migrate proactively:
  • Send code_challenge + code_challenge_method=S256 on /login and code_verifier on /exchange.
  • Send a random state on /login and verify it on callback.
  • Register your redirect_uri via sync-clients.mjs.
Search Cloudflare logs for CLIENT_ADOPTION_GAP filtered by your redirect_uri to confirm your app is clean before a flip.

Error handling

When a request is malformed under enforcement (missing PKCE, missing state, unregistered redirect, etc.), the hub follows OAuth 2.0 §4.1.2.1 based on whether the redirect_uri is trusted:
  • Trusted (a registered client or loopback) — the hub redirects back to your app with redirect_uri?error=<code>&error_description=<msg>&state=<state>. Handle these on your callback route like any other OAuth error.
  • Untrusted or missing — the hub never bounces to that target; it shows the user a styled same-origin error page at /auth-error?error=<code>&error_description=<msg>.
Common error codes: invalid_request (bad or missing code_challenge, missing state), unregistered_client, insecure_redirect_uri, access_denied (user cancelled consent), pkce_failed and invalid_or_used_code (on /exchange).

Audit log

Every login and exchange step now writes to a durable, append-only audit log — login.start, login.rejected (with a reason), auth.authenticated, register.start, auth.code_issued, consent.grant, consent.denied, auth.exchange (success or pkce_failed), and logout among others. Email addresses and other PII are no longer written to auth logs; WorkOS user ids are stored hashed and codes are stored as SHA-256 hashes so a code’s whole lifecycle can be correlated without keeping the secret. This is transparent to your app — no changes required — but it means operators can answer “which client was authorised for which user, when, from where, and did it succeed?” from the audit table.

Troubleshooting

Your redirect_uri isn’t in the allowlist for that environment. Add it via sync-clients.mjs and re-run the sync for the target env — it takes effect within about 60 s.
REQUIRE_PKCE is on for this env and your /login request has no code_challenge. Generate a code_verifier, derive the S256 code_challenge, and include code_challenge_method=S256.
The code_verifier you sent doesn’t hash to the code_challenge from /login. Make sure you’re re-reading the same verifier you stored before the redirect and haven’t double-encoded it. The code is invalidated on failure — restart the login flow.
The user hit Cancel on the consent screen. The code has been invalidated server-side. Show a “sign in was cancelled” state and let the user retry.
Your redirect_uri uses http:// against a non-loopback host, or a rejected scheme (javascript:, data:, blob:, file:, vbscript:). Use https:// in production; loopback http:// is fine for local dev.
The state returned doesn’t match what you stored. Don’t exchange the code. This usually means the user opened the callback in a different tab / storage context, or the login was initiated from a third party (CSRF). Restart the flow.

Next steps

Session signing with SignX

Sign transactions in the browser after login without per-action wallet confirmations.

Smart accounts

Add on-chain enforcement of which message types a session can sign.

Networks and endpoints

Look up the Auth Hub base URL for the target network.

Authentication overview

Pick the right auth pattern for each IXO API surface.