AuthenSeeDocs

Hosted pages

The redirect-based integration and the full ceremony narrative — enrollment, authentication, policy upgrades, and persona linking with the QR handoff.

What you'll build: a login flow where your backend mints a session, the browser is redirected to an AuthenSee-hosted page for the ceremony, and your callback route completes the login server-side.

When to use this: when you prefer a full-page handoff over the popup embed — for example on mobile web, or in apps that already model auth as a redirect (OAuth-style). Both surfaces run the identical hosted flow; only the way the user enters and leaves it differs. This page also documents every ceremony the hosted flow can run — enrollment, authentication, policy upgrade, and persona linking — and exactly what your callback receives in each case.

How it works

Your backend               AuthenSee hosted page        Auth server
  |                             |                          |
  |  1. POST /v1/sessions       |                          |
  |     (x-api-key: sk_...)  ---|------------------------> |
  |  <-- { sessionId, flowCode, |                          |
  |        hostedUrl, ... }     |                          |
  |                             |                          |
  |  2. Redirect user to        |                          |
  |     hostedUrl               |                          |
  |   ----------------------->  |                          |
  |                             |                          |
  |         3. Redeems the single-use flowCode             |
  |            POST /v1/hosted/flow-code/redeem ---------> |
  |            <-- { sessionToken } (held in memory)       |
  |            GET /v1/sessions/current (Bearer) --------> |
  |            User completes the ceremony                 |
  |                             |                          |
  |         4. SDK generates ZK proof (WASM, on-device)    |
  |                             | --- POST /v1/verify ---> |
  |                             | <-- { authResultCode }   |
  |                             |                          |
  |  5. Redirect to your callback                          |
  |  <-- callbackUrl?authResultCode=ar_...&sessionId=...   |

Create a session on your backend

// Express example
app.get('/auth/start', async (req, res) => {
  const response = await fetch('https://api.authensee.com/v1/sessions', {
    method: 'POST',
    headers: {
      'x-api-key': process.env.AUTHENSEE_SECRET_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      scope: 'full',
      externalUserId: req.user.id,
      callbackUrl: 'https://app.example.com/auth/callback',
    }),
  });
 
  const session = await response.json();
  res.redirect(session.hostedUrl);
});

Pick the scope for the job: enroll for first-time setup, authenticate for a returning login, full when the hosted flow should decide (it checks the account's provider binding and branches accordingly). See POST /v1/sessions for every field, including enrollMode and providerSubject.

The response carries a single-use flowCode inside a ready-to-use hostedUrl (https://auth.authensee.com/flow/{flowCode}). Send the user to hostedUrl — the sessionToken in the same response is for server-side session-token calls only and must never reach a browser URL.

Send the user to the hosted URL

A plain redirect (as above) or the popup — the hosted flow behaves identically in both. On arrival, the page:

  1. Redeems the single-use flowCode for an in-memory session token — there is no session cookie anywhere
  2. Validates the session via GET /v1/sessions/current, which returns your co-brand theme, the session scope, and the ceremony descriptor for your current factor combination
  3. Runs the right ceremony (below), generating ZK proofs in the browser via WASM — the network only ever sees the proof and its public inputs
  4. Redirects to your callbackUrl with the outcome

Because the token lives only in memory and only the single-use flowCode ever travels in a URL, a mid-flow full-page refresh restarts the flow — the code can't be redeemed twice. The pages also deny framing (frame-ancestors 'none'), which is why there is no iframe surface. See hosted flow surface security.

The user completes a ceremony

Which ceremony runs depends on the session scope, the account's state, and your current policy. Proof generation takes a few seconds in the browser; the flow shows progress throughout.

First-time enrollment

The user completes your policy's factors — registers a passkey, and picks memorable image points or performs a motion gesture, depending on your factor combination. Everything is hashed on-device; only a single aggregate commitment reaches the server (POST /v1/enrollments). The persona also opportunistically registers its passkey-only scheme in the same call, future-proofing it against a later policy switch.

Authentication

A returning user re-completes their factors. The flow binds a challenge, generates the proof on-device, and submits it (POST /v1/verify). A wrong answer fails locally inside the prover — no server round-trip, nothing learned by anyone. On success the server mints the one-time authResultCode your callback receives.

Policy upgrade

If you tightened your factor combination since the user enrolled, their login fails with 409 policy_upgrade_required — and the hosted flow turns that into a guided upgrade instead of a lockout. The user proves a factor they already have (commonly the universal passkey-only factor), that same proof authorizes adding the new on-policy scheme (POST /v1/enrollments/add), and the login then completes normally. Nothing is deleted; the persona gains an additional scheme.

Linking an existing persona

A user who already enrolled through another AuthenSee provider is offered "log in with your existing persona" instead of enrolling fresh factors. They prove any scheme their persona already holds, and the proof authorizes linking the persona to your provider (POST /v1/personas/:personaId/provider-links).

Because passkeys can't be enumerated across origins, this is a prompt, not a lookup — and when the current device doesn't hold the right passkey (say, the user is on a work desktop but their passkey is on their phone), the flow offers a QR handoff:

  1. The desktop mints a single-use handoff code and renders it as a QR code
  2. The phone scans it, redeems the code for its own device-scoped session token, and completes the proof there
  3. The desktop long-polls GET /v1/sessions/result and resolves the moment the phone's proof lands — it never sees the proof itself

The handoff is also used for ceremonies that need device capabilities the current device lacks — a motion-gesture policy on a desktop without an accelerometer, for example (GET /v1/sessions/current reports this in its ceremony descriptor).

Handle the callback

The user returns to your callbackUrl with query parameters that differ by ceremony:

Ceremony outcomeCallback parameters
Authentication (login)authResultCode=ar_...&sessionId=...
Enrollment (no login performed)sessionId=... — no result code to exchange
Persona linklinked=1&providerSubject=sub_...&sessionId=... — no authResultCode
ParameterDescription
authResultCodeOne-time result code — exchange it server-side with your secret key. Absent for enroll-only flows and persona links.
linkedPresent ("1") when an existing persona was linked rather than newly enrolled
providerSubjectYour provider-scoped stable alias for the persona, when applicable
sessionIdThe session ID, for correlation with the session you minted
app.get('/auth/callback', async (req, res) => {
  const { authResultCode, linked, providerSubject } = req.query;
 
  if (authResultCode) {
    // Exchange the one-time code server-side with your secret key.
    const result = await authensee.exchangeAuthResult(authResultCode);
    req.session.subject = result.providerSubject;
    return res.redirect('/dashboard');
  }
 
  if (linked === '1') {
    // Existing persona linked to your provider. Store the subject;
    // the user's next login will produce an authResultCode.
    await recordAuthenseeSubject(req.user.id, providerSubject);
    return res.redirect('/dashboard');
  }
 
  // Enroll-only completion, or the user abandoned the flow.
  res.redirect('/account/security');
});

When using the popup, this same callback page just calls relayCallback() — it relays the parameters to your opener page over a BroadcastChannel instead of you handling a top-level redirect.

Branding

Hosted pages are co-branded: AuthenSee owns the frame and you contribute a small, consistent identity — logo, display name, one brand color, one copy line, and a light or dark surface. The frame keeps a constant "Secured by AuthenSee" mark. Configure it on the Brand identity page of your dashboard; the projection is delivered to the hosted pages as a theme on the session. See theming.

Troubleshooting

POST /v1/sessions fails with VALIDATION_ERROR about the callback URL. The callbackUrl origin must exactly match an entry on your provider's allowed-callback-origins list (scheme, host, and port). An empty allowlist rejects every callback URL.

POST /v1/sessions with scope: "authenticate" fails with NOT_FOUND. The externalUserId you passed has no AuthenSee enrollment yet. Mint an enroll or full session for first-time users, or let full scope branch automatically.

POST /v1/sessions fails with CONFLICT ("setup is incomplete"). The account started enrolling but never finished. Send the user through an enroll or full flow to complete setup.

The user refreshed mid-flow and was sent back to the start. Expected: the flowCode is single-use and the session token lives only in memory, so a refresh can't resume. Mint a fresh session.

A returning user was asked to add a factor. You changed your factor combination; their login hit policy_upgrade_required and the flow ran the guided upgrade. This is a one-time step per user per policy change.

The exchange fails with VALIDATION_ERROR ("already consumed" / "expired"). Result codes are single-use and short-lived. If your callback handler can retry (queues, at-least-once delivery), deduplicate by sessionId before exchanging.

Next

On this page