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_verifierand CSRFstate) between the redirect out and the callback in — typicallysessionStoragefor 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-timecode can be minted (/api/auth/login, /api/auth/register/start, /api/auth/complete) so the requirements can’t be downgraded mid-flow:
- PKCE (S256) — send a
code_challengeon the initial redirect and the matchingcode_verifieron exchange. - CSRF
state— send a randomstateon the initial redirect; verify it matches on callback. - 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). - Consent — the user sees a Google-style consent screen showing your app’s name and logo the first time, then again after 90 days.
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 freshcode_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).
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.
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.
/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 yourredirect_uri with code and state on success, or error and error_description on failure. Verify state matches what you stored, then exchange the code.
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.
code_verifier returns an error and marks the code as consumed.
Redirect URI registration
The hub matches the incomingredirect_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=trueflips per environment.
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_urivalues.
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.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/start→400. - At
/complete→403.
/callback, /verify-email) is unaffected.
The consent screen
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).
(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:
"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=S256on/loginandcode_verifieron/exchange. - Send a random
stateon/loginand verify it on callback. - Register your
redirect_uriviasync-clients.mjs.
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, missingstate, 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>.
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
`error=unregistered_client` on `/login`
`error=unregistered_client` on `/login`
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.`error=invalid_request` with `pkce_required`
`error=invalid_request` with `pkce_required`
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.`pkce_failed` on `/exchange`
`pkce_failed` on `/exchange`
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.`error=access_denied`
`error=access_denied`
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.
`error=insecure_redirect_uri`
`error=insecure_redirect_uri`
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.State mismatch on callback
State mismatch on callback
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.