RFC: Migrating Authentication to Clerk

Date: 2026-06-23

Status: Accepted (2026-07-02); Revised (2026-07-03) reworked to the Clerk-blessed client-side integration and invite-code retirement, superseding the BFF/dual-stack decisions. See §Decisions for what changed and why.

Related: Lazy Account Provisioning, Multi-Accounts (A-3402, §2f authorization & API keys migration complete, plan retired)

Background

AX runs a fully homerolled authentication (authN) stack. It works, but it makes us the maintainers of credential security and the immediate pain it gives us a 2FA implementation we have to build, test, and own end to end. We want to lift user creation and human authentication onto Clerk (clerk.com) to get a real, maintained MFA (TOTP + SMS + backup codes) without writing it ourselves, while keeping authorization (authZ) homerolled the account_permissions ACL the Multi-Accounts work just finished building is ours and stays ours.

This is not greenfield. The codebase already carries a partially-built Clerk integration:

The revised shape is Clerk's blessed path: ClerkJS runs in the browser and drives sign-in/sign-up/MFA/reset against Clerk's Frontend API directly, using custom flows (useSignIn/useSignUp hooks) underneath our existing themed auth pages Clerk owns the protocol, we own every pixel. The only new server-side authN surface is a token-exchange endpoint: the GUI presents a Clerk session JWT once, api-gateway verifies it (JWKS) and mints the AX session token exactly as today. Everything per-request stays AX-owned.

The current homerolled authN surface (what we are replacing or keeping)

Concern Where Disposition under this RFC
Password hash + verify (Argon2) rs/sdk-internal/crypto/src/secrets.rs:5-27; login validate_password auth.rs:161-185 Clerk (Clerk stores/verifies credentials)
Opaque session tokens in Redis (TokenManager, session:token:*, session:user:*) rs/sdk-internal/web-auth/src/token_manager.rs, redis_keys.rs:54-60 Keep (AX session minted at token exchange see §2)
AuthState trait (token UserId, UserId DbUser) rs/sdk-internal/web-auth/src/lib.rs:29-45; impl auth.rs:51-66 Keep the single integration seam
Token extraction (cookie ax_token / Authorization: Bearer) web-auth/src/lib.rs:108-121 Keep
Homerolled TOTP 2FA (enabled_2fa, totp_cipher, nonce, TotpManager) entities.rs:30-32; api-gateway/src/totp_manager.rs; routes auth_routes.rs:451-699; login check auth.rs:120-152 Clerk; retire entirely (this is the whole point)
API keys (HMAC-style key+secret, Argon2) AuthenticationMethod::ApiKeySecret auth.rs:200-241; provision_api_key; DbApiKey entities.rs:625-697 Keep, unchanged machine auth, Clerk doesn't do this
Password reset codes password_reset_codes; reset_password public_routes.rs:347-442 Clerk (credentials move; flow runs in the browser)
Invite codes invite_codes table db/postgres/1.sql:2-9; signup public_routes.rs:188-261 Retire. Identity signup opens; the real gate moves to account materialization (lazy-provisioning RFC)
is_onboarded / is_frozen / is_close_only / fees DbUser, update_status entities.rs:161-198 Keep AX authZ/state, never a Clerk concern

Goals

Non-Goals

Design

1. The boundary

            ┌────────── Clerk ──────────┐        ┌──────────── AX ────────────┐
  human ──▶ │ credentials, password,    │        │ users row (UserId = ULID), │
 (browser   │ TOTP/SMS/backup-code MFA, │ ─────▶ │ clerk_id link, account_    │
  ClerkJS)  │ email verification, bot   │ token  │ permissions authZ,          │
            │ protection, social/passkey│ exch.  │ is_onboarded/frozen, API    │
            └───────────────────────────┘        │ keys, sessions              │
                                                  └─────────────────────────────┘

Clerk answers "is this human who they say they are (incl. second factor)?" and under this revision it answers it directly to the browser. AX answers "which AX user is that, and what may they do?" The link is users.clerk_id, established at the token exchange.

2. The AuthState seam keep AX sessions (unchanged decision)

Every authenticated request resolves identity through one trait: AuthState::decode_token (token UserId) and get_user (UserId DbUser), implemented for AppState at auth.rs:51-66.

§2a stands: Clerk at the auth event, AX session per request. What the revision changes is only where the auth event happens: in the browser via ClerkJS instead of proxied through api-gateway. The handshake:

  1. GUI completes a Clerk sign-in (custom flow); ClerkJS holds a Clerk session.
  2. GUI calls session.getToken() and POSTs the short-lived Clerk session JWT to a new /login/clerk endpoint.
  3. api-gateway verifies the JWT against Clerk's JWKS (cached; iss/azp checked against frontend_domain/publishable key), extracts the Clerk user id (sub), resolves users.clerk_id → UserId (materializing an identity-only row on first touch, §3), and mints the AX opaque session token via the existing TokenManager, setting the ax_token cookie exactly as today.

decode_token is unchanged; there is still no per-request runtime dependency on Clerk JWKS verification happens only at the exchange moment. API keys, logout/bulk-revocation (session:user:*), IP allowlists, and middleware keep working as-is. The rejected alternative trusting Clerk JWTs per request remains rejected for the same reasons as before.

3. Signup Clerk-owned, invite codes retired

Signup moves entirely into the browser: the GUI's themed sign-up page drives Clerk's useSignUp custom flow (email + password, then Clerk's email verification step; the page renders the clerk-captcha element so Clerk's bot protection applies). There is no AX signup endpoint anymore.

Invite codes retire. They gated identity creation; under the lazy-provisioning RFC the scarce, risk-bearing resource is the funded trading account, which is materialized only at funding/onboarding with its own gates. An identity-only user can log in and look at walls; they cannot trade, deposit, or withdraw. If marketing ever wants scarcity back, Clerk's restricted/invitation-only signup modes can express it at the Clerk layer no AX tables needed.

AX user materialization happens at first token exchange. When /login/clerk sees a verified Clerk JWT whose sub matches no users.clerk_id, it mints a UserId locally and writes an identity-only DbUser (per the lazy-provisioning RFC no trading account, no EP3 round-trip) with clerk_id, email, and username from the verified Clerk claims. This replaces the old signup transaction entirely: there is no invite-decrement/Clerk-create/AX-write compensation dance because there is nothing to compensate Clerk signup and AX materialization are decoupled and each idempotent (clerk_id is unique; a retried exchange converges).

Email verification is enforced by Clerk instance settings (verified email required to complete sign-up), not by AX endpoints. The exchange endpoint additionally refuses JWTs whose email is unverified as a backstop. Imported users are marked verified at import time (§7).

A backstop reconciler (BAPI list-users vs users.clerk_id) sweeps for Clerk-users-never-exchanged and AX-rows-with-dangling-clerk_id; a Clerk user.created/user.deleted webhook is an optional optimization, not load-bearing.

4. Login + MFA retire the homerolled TOTP

5. API keys explicitly out of scope, unchanged

The AuthenticationMethod::ApiKeySecret path (auth.rs:200-241), key provisioning (provision_api_key), DbApiKey, the read-only/full-access split, and IP allowlists are untouched. Clerk is human-auth; machine credentials remain AX-issued and AX-verified.

AuthenticationMethod::UsernamePassword is deprecated at cutover for programmatic use: once hashed_password verification retires, the gateway can no longer check a password itself, and headless clients cannot drive a browser flow. Machine access is API keys, full stop. If telemetry shows real programmatic username/password usage that cannot move to API keys, the fallback is a thin BAPI verify_password bridge a decision to make on evidence, not preemptively.

6. Password reset and change to Clerk, in the browser

Password reset (forgot-password emailed code) and password change become Clerk custom flows on our themed screens. Retire password_reset_codes and the reset_password route (public_routes.rs:347-442) at cutover. Note: after cutover the server never sees plaintext passwords, so AX's stored hashed_password goes stale as users change passwords this bounds the rollback window (§7) and is accepted.

7. Migrating existing users bulk import, then a hard cut

Passwords import directly no reset, no re-hash. hash_secret (rs/sdk-internal/crypto/src/secrets.rs:6-17) is Argon2::default(), which emits a standard Argon2id, v=19, PHC-format digest ($argon2id$v=19$m=…,t=…,p=…$salt$hash). Clerk's Backend API CreateUser accepts exactly this via password_digest + password_hasher: "argon2id", so we feed each user's existing hash verbatim (client already merged, #2846).

The revision replaces the per-user dual-stack window with a per-environment hard cut behind a GUI/env flag:

  1. Bulk import (complete, verified). Import every current user with their stored Argon2id digest, mark emails verified, write back clerk_id. Gate on zero unimported rows and spot-check logins in sandbox. Re-run the import diff until clean the import is idempotent on external_id.
  2. Flip the flag (sandbox demo prod): the GUI serves the ClerkJS login/ signup flows and the exchange endpoint; homerolled login/signup routes stay deployed but unreached.
  3. Rollback = flip the flag back. The homerolled path still works because hashed_password is still populated. The window's known leak: passwords changed via Clerk during the window are not mirrored back to AX, so those users would need a support reset on rollback. Keep the window short and the population informed.
  4. Retire (§8) once each environment has run clean.

MFA migration is a non-issue. MFA is disabled today nothing in totp_cipher/nonce to port. Clerk MFA comes up as net-new after cutover.

8. Rollout and retirement

Affected components

Component Change
api-gateway new /login/clerk Verify Clerk session JWT (JWKS, cached); resolve/materialize clerk_id → UserId; mint AX session (§2).
api-gateway signup (public_routes.rs:182-280) Retire at cutover signup moves to the browser; materialization moves to the exchange.
api-gateway login (public_routes.rs:65-114, auth.rs:187-265) Homerolled branch retired at cutover; UsernamePassword deprecated for programmatic use (§5).
api-gateway 2FA (totp_manager.rs, auth_routes.rs:451-699, auth.rs:120-152) Delete; MFA moves to Clerk in-browser.
clerk_fapi/ (server-side FAPI client) Delete post-cutover the browser owns FAPI now.
clerk/ (BAPI client, #2846) Keep: bulk import, admin ops, reconciler, optional webhook processing.
DbUser (entities.rs) Use clerk_id; drop enabled_2fa/totp_cipher/nonce, then hashed_password, post-cutover.
ax-web-auth AuthState/TokenManager Unchanged the reason §2a survives the revision intact.
API-key path (auth.rs:200-241, provision_api_key) Unchanged.
password_reset_codes + reset_password Retire at cutover.
Invite codes (invite_codes, signup consumption, admin_routes.rs:487-515) Retire at cutover.
GUI auth screens Keep the themed pages; replace the API layer beneath them with ClerkJS custom flows (useSignIn/useSignUp, TOTP enrollment, reset) + the token exchange; add clerk-captcha element to signup; publishable key in GUI env.
Migration tooling Bulk hash-import job + import-diff verification; backstop reconciler.

Decisions

2026-07-02 (original acceptance)

2026-07-03 (revision to the blessed path)

Open questions

Appendix: source index

Concern Location
Login route / cookie set rs/api-gateway/src/public_routes.rs:65-114
authenticate_and_create_token, password/api-key/2FA branches rs/api-gateway/src/auth.rs:187-265
validate_password (Argon2) auth.rs:161-185, rs/sdk-internal/crypto/src/secrets.rs:5-27
AuthState trait + impl rs/sdk-internal/web-auth/src/lib.rs:29-45; auth.rs:51-66
TokenManager (opaque Redis sessions) rs/sdk-internal/web-auth/src/token_manager.rs; keys redis_keys.rs:54-60
Token extraction (cookie/bearer) web-auth/src/lib.rs:108-121
Homerolled TOTP api-gateway/src/totp_manager.rs; routes auth_routes.rs:451-699; login check auth.rs:120-152; fields entities.rs:30-32
API-key auth auth.rs:200-241; DbApiKey entities.rs:625-697; provision_api_key
Password reset public_routes.rs:347-442; DbPasswordResetCode entities.rs:480-488
Invite codes db/postgres/1.sql:2-9; signup public_routes.rs:188-261; admin create admin_routes.rs:487-515
Clerk BAPI client (kept) rs/api-gateway/src/clerk/backend.rs
Clerk FAPI client (retire post-cutover) rs/api-gateway/src/clerk_fapi/
ClerkConfig + env load rs/api-gateway/src/clerk/mod.rs:8-15; main.rs:78-89; state.rs:57
clerk_id column rs/sdk-internal/db/src/entities.rs:20, :83
is_onboarded etc. (stays AX) DbUser::update_status entities.rs:161-198