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)
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:
rs/api-gateway/src/clerk_fapi/ (create_sign_in,
create_sign_up,
attempt_second_factor,
TOTP
ops, end_session,
change_password),
built
for
the
original
BFF
shape.
Under this
revision
it
is
retired
—
the
browser
talks
to
Clerk
directly,
so
the server
never
drives
FAPI.
rs/api-gateway/src/clerk/ (#2846):
create_user
(incl.
the
password_digest
+
password_hasher: "argon2id"
import
form),
delete_user,
mark_email_verified.
Kept
— this
is
the
bulk-import
and
admin
surface.
ClerkConfig
(secret_key,
frontend_key,
frontend_domain, rs/api-gateway/src/clerk/mod.rs),
loaded
from
CLERK_SECRET_KEY
/ CLERK_PUBLISHABLE_KEY
/
CLERK_FRONTEND_DOMAIN
and
threaded
through service.rs
→
state.rs.
clerk_id: Option<String>
column
already
on
DbUser (rs/sdk-internal/db/src/entities.rs:20,
persisted
at
:83),
None
for
every user
today.
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.
| 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 |
users
row (keyed
by
an
AX-minted
UserId/ULID)
remains
canonical;
Clerk
is
a
linked credential
provider
via
users.clerk_id.
Every
downstream
system
— account_id
derivation,
EP3
username,
TigerBeetle
ledger
ids,
every
money
row
— keys
on
the
AX
UserId,
so
it
cannot
move
to
Clerk.
useSignIn/useSignUp)
under
the
existing
GUI
screens
— no
visual
regression
to
Clerk-hosted
pages
or
stock
widgets.
account_permissions
ACL
stays
ours
(Multi-Accounts
A-3402
§2f).
UserId/AccountId
derivation.
UserId
stays
an
AX
ULID; clerk_id
is
a
side-link,
not
a
replacement.
┌────────── 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.
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:
session.getToken()
and
POSTs
the
short-lived
Clerk
session
JWT
to a
new
/login/clerk
endpoint.
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.
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.
useSignIn
custom
flow:
password first
factor,
then
whatever
second
factor
the
user
has
enrolled
(TOTP,
SMS, backup
code)
—
the
flow's
needs_second_factor
status
drives
our
existing 2FA
challenge
UI.
On
completion
the
GUI
performs
the
token
exchange
(§2)
and the
app
proceeds
with
the
ax_token
cookie
exactly
as
today.
appearance
API
are
permitted;
primary
auth screens
stay
fully
custom.
totp_manager.rs,
the enabled_2fa/totp_cipher/nonce
columns,
the
setup_2fa/confirm_2fa/ disable_2fa
routes
(auth_routes.rs:451-699),
the
login
verify_2fa_if_enabled check
(auth.rs:120-152),
and
the
require_2fa/Needs2fa-redirect
machinery. This
is
the
working
2FA
deliverable.
Because
MFA
is
disabled
today
(no user
has
an
enrolled
second
factor),
this
retirement
is
a
pure
deletion;
Clerk MFA
is
then
switched
on
as
net-new
capability.
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.
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.
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:
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.
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.
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.
clerk_config
presence
(backend)
+
publishable
key
&
flow
flag
(GUI
env)
are the
switches,
per
environment
(sandbox
→
demo
→
prod).
validate_password;
totp_manager.rs
+
2FA
routes/columns; password_reset_codes;
the
server-side
FAPI
client
(clerk_fapi/,
dead under
this
shape);
invite_codes
(table
+
signup
consumption
+
admin
create); finally
hashed_password.
| 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. |
AuthState/TokenManager
unchanged.
is_frozen
(and
freeze/close-only
generally)
is
an AX
authZ
concern
only
—
we
do
not
mirror
it
into
Clerk
suspension.
No
sync job,
no
second
source
of
truth.
appearance
theming
is
acceptable
for
secondary
surfaces
(MFA enrollment
modals)
at
implementer's
discretion.
UsernamePassword
programmatic
auth:
deprecated
at
cutover
(§5);
machine auth
is
API
keys.
BAPI
verify_password
bridge
only
if
usage
evidence demands
it.
account_permissions.
Out
of
scope;
noted.
| 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 |