Data modeling
How to structure your users table around AuthenSee — as a second factor, as passwordless primary login, or as step-up for sensitive actions.
AuthenSee holds your users' secrets so you don't have to. But your backend still needs a row per user, and it needs to recognize a returning user when AuthenSee tells you "this is them." This page is the mental model for how your data relates to AuthenSee — and three concrete ways to structure it depending on what AuthenSee is for in your product.
The one join key: providerSubject
There is exactly one value you store to recognize a user across logins:
| Value | Who owns it | Where it comes from |
|---|---|---|
externalUserId | You | You choose it and pass it in when you mint a session. It's your own user ID — a UUID, a handle, an email, whatever your app already uses. |
providerSubject | AuthenSee | You receive it after a ceremony completes — as the sub claim of the exchanged auth-result JWT, or as a providerSubject query param on a link. It's a stable, provider-scoped alias for the user's persona: the same on every future login, and different at every other provider (so users can't be correlated across services). |
The golden rule
Store providerSubject on your user row. On every subsequent login, exchange the authResultCode server-side, read sub, and look your user up by it. externalUserId is how you address a user going in; providerSubject is how AuthenSee names them coming back.
The rest of this page is that rule applied three ways.
Pattern 1 — AuthenSee as a second factor
You already have primary auth (password, magic link, OAuth). AuthenSee is the second factor: after the user proves who they are your existing way, you run an AuthenSee ceremony to prove they hold their enrolled factor.
Your canonical identity is unchanged — your existing users.id. You add two columns:
Enrollment (once, after the user opts into 2FA): mint a session with scope: "enroll" and externalUserId: <users.id>, send them through the hosted flow, and on completion exchange the code and store the sub:
Login (every time): after the password check passes, mint a session with scope: "authenticate" and externalUserId: <users.id>. When the ceremony completes, exchange the code and verify the returned sub matches the one you stored:
If they match, the second factor is proven. If authensee_subject is NULL, the user hasn't enrolled — fall back to your existing flow or prompt them to set it up.
Why bind externalUserId here
Because you already know which user is authenticating (they passed the first factor), you pass their ID as externalUserId. AuthenSee then constrains the ceremony to that account, and a mismatch — someone proving a different persona — fails before it reaches your callback.
Pattern 2 — AuthenSee as passwordless primary login
This is what the AuthenSee sample social app does, and what most greenfield integrations want. There is no password. Your canonical identity is a handle (or user ID), and AuthenSee is the credential — think of providerSubject as the password, except your backend never stores or verifies anything secret.
The whole auth surface is two flows:
Sign up
The user picks a handle. Mint a session with scope: "enroll". You don't have a user ID to bind yet — externalUserId is optional, so either omit it and rely purely on the returned subject, or pass a provisional value (e.g. the pending handle) if you want the binding echoed on the result. When the ceremony completes, exchange the code and create the row with the returned sub:
Log in
The user comes back. Mint a session with scope: "authenticate" and no bound externalUserId (you don't know who they are yet — that's the point). AuthenSee discovers their persona from the passkey on the device. On completion, exchange the code and look the user up by subject:
A hit is a logged-in user. A miss means this persona has never signed up here — send them to the sign-up flow.
`providerSubject` is the password, minus the liability
In a password system you store a hash and compare secrets. Here you store providerSubject in plaintext — it's just an opaque alias, useless to anyone else and unique to your provider. The actual secret (the passkey, the factor) never leaves the user's device, and AuthenSee proves control of it with a zero-knowledge proof. You get "look up user by credential" ergonomics with nothing sensitive at rest.
One persona, many of your users? No.
Within your provider, one persona maps to exactly one providerSubject, so the UNIQUE constraint on authensee_subject is real and safe. The same human at a different provider gets a different subject — that isolation is a feature, not a collision to handle.
Pattern 3 — Step-up for sensitive actions
You can reuse an enrolled AuthenSee factor to gate individual high-value actions (change payout details, delete account, sign a transaction) without a full re-login. You already have users.authensee_subject from Pattern 1 or 2; step-up adds no schema.
At the moment of the sensitive action, mint a short-TTL session with scope: "authenticate" and externalUserId: <users.id>, run the ceremony, and only proceed once you've exchanged a fresh authResultCode whose sub matches the stored subject. Record it if you need an audit trail:
Because each ceremony yields a single-use authResultCode bound to one session, a step-up proof can't be replayed for a different action.
Two things to get right
Exchange the code server-side — always
The authResultCode in your callback is a one-time code, not proof of anything on its own. Your backend must call POST /v1/auth-results/exchange with your secret key (or verify the returned JWT against the JWKS) before trusting the identity. Never log a user in from the front-end callback params alone.
Store the subject, not the persona ID
There is no user-facing "persona ID" in your callback, by design. providerSubject is the only stable identifier you get, and it's already scoped to you. Don't try to reconstruct a global user identity across providers — you can't, and that's the privacy guarantee.
Next
- Sessions API — minting the sessions each pattern uses
- Auth results API — exchanging the code and reading
sub - Hosted pages guide — the ceremony your users actually see