AuthenSeeDocs

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:

ValueWho owns itWhere it comes from
externalUserIdYouYou 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.
providerSubjectAuthenSeeYou 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:

CREATE TABLE users (
  id             uuid PRIMARY KEY,
  email          text UNIQUE NOT NULL,
  password_hash  text NOT NULL,          -- your existing first factor
 
  -- AuthenSee second factor:
  authensee_subject   text UNIQUE,       -- providerSubject; NULL until enrolled
  authensee_enrolled_at timestamptz
);

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:

UPDATE users
SET authensee_subject = $sub, authensee_enrolled_at = now()
WHERE id = $userId;

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:

SELECT authensee_subject FROM users WHERE id = $userId;
-- require: stored subject === exchanged JWT `sub`

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.

CREATE TABLE users (
  id                 uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  handle             text UNIQUE NOT NULL,     -- @alice — your canonical public ID
  display_name       text,
  authensee_subject  text UNIQUE NOT NULL,     -- providerSubject IS the login credential
  created_at         timestamptz NOT NULL DEFAULT now()
);

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:

INSERT INTO users (handle, authensee_subject)
VALUES ($handle, $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:

SELECT id, handle FROM users WHERE authensee_subject = $sub;

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:

CREATE TABLE step_up_events (
  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id      uuid NOT NULL REFERENCES users(id),
  action       text NOT NULL,          -- 'change_payout', 'delete_account', …
  auth_result  text NOT NULL,          -- the exchanged result id, for audit
  created_at   timestamptz NOT NULL DEFAULT now()
);

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

On this page