AuthenSeeDocs

Enrollment

Enroll users with security questions and passkeys. Learn what happens on-device vs on the server.

Enrollment is the process of registering a user's authentication factors with AuthenSee. The user completes the provider's required factors on-device (a passkey, plus image points or a motion gesture depending on policy), and only an aggregate cryptographic commitment is sent to the server. The underlying answers and gesture data never leave the device.

This is the on-device engine, not a third-party integration path. enroll() runs the WebAuthn passkey ceremony, which only succeeds on AuthenSee's own origin (the hosted flow and first-party apps). To enroll users from your own app, launch the hosted popup — see the embed guide. This page documents how enrollment works inside that flow.

Step 1: Identify the user

Before enrolling, you must link your application's user ID to an AuthenSee persona. If the user has already enrolled with another provider, their existing factors are reused automatically.

const persona = await AuthenSee.identify('user_12345');
 
console.log(persona.personaId);   // stable AuthenSee persona ID
console.log(persona.personaType); // 'human' or 'agent'

The externalUserId you pass is mapped to a stable personaId scoped to your provider. A single persona can be linked to multiple providers.

Step 2: Enroll a factor + passkey

Which scheme a persona enrolls is determined by the provider's factor combination policy. The default, passkey_and_image_points, registers an aggregate auth_commitment over (image-points root, passkey commitment) — both factors are bound to a single commitment, and a successful auth attests to both atomically.

import { createPasskey } from '@your-app/passkey-ceremony';
// or implement your own with navigator.credentials.create(...)
 
await AuthenSee.identify('user_12345');
 
// Run the WebAuthn registration ceremony (browser owns the DOM call)
const challengeBytes = new Uint8Array(32);
crypto.getRandomValues(challengeBytes);
const passkey = await createPasskey({
  rpId: window.location.hostname,
  rpName: 'Acme Corp',
  userId: AuthenSee.getPersona().id,
  userName: 'user_12345',
  challengeBytes,
});
 
// Enrol the aggregate (passkey_question_v1)
const result = await AuthenSee.enroll('image_points', {
  questions: [
    { answer: 'the-three-points-the-user-picked' },
  ],
  passkey: {
    credentialId: passkey.credentialId,
    pubkeyX: passkey.pubkeyX,
    pubkeyY: passkey.pubkeyY,
    rpId: window.location.hostname,
  },
});
 
console.log('auth_commitment:', result.merkleRoot);

passkey_question_v1 is single-answer by design — it aggregates exactly one image-points answer + one passkey signature in the same proof. A provider on the passkey_and_behavior policy enrolls AuthenSee.enroll('behavior', {...}) instead (a motion gesture in place of the image-points answer), and a provider on passkey_only enrolls AuthenSee.enroll('passkey', { passkey }) with no second factor at all. Every persona also opportunistically registers the passkey-only scheme alongside its primary factor at enrollment — see Provider policy.

What happens on-device

During enrollment, the SDK performs these steps entirely on the user's device:

  1. Normalize answers — Answers are lowercased, trimmed, and normalized to ensure consistent hashing.
  2. Hash each answer — Each answer is hashed using Poseidon2: leaf = Poseidon2(Poseidon2(normalize(answer)), salt) where the salt is derived deterministically from the persona ID and question index.
  3. Build a Merkle tree — The leaf hashes are assembled into a Merkle tree using Poseidon2.
  4. Extract the root — The Merkle root is a single field element that commits to all answers without revealing any of them.
  5. Store witness locally — The SDK persists the Merkle path, salt, and the question Merkle root in sessionStorage (under the authensee:enrollment:v2: prefix), alongside the V1 passkey witness. The answer hash itself is never persisted — if it were, anyone with read access to sessionStorage could replay the proof without typing the answer. The hash exists only in local scope while the tree is being built; raw answers are discarded immediately.

What is sent to the server

Only the aggregate scheme commitment is sent to the server:

POST /v1/enrollments
{
  "personaId": "01917f8a-…",
  "schemeId": "passkey_question_v1",   // or behavior_passkey_v1 / passkey_only_v1 / agent_keypair_v1
  "commitment": "0x1a2b3c..."   // auth_commitment = Poseidon2(question_root, passkey_commitment, 0, 0)
}

The server returns { enrolled, enrollmentId, schemeId, commitment, factors } and stores exactly one row keyed on (persona, scheme) — one row per enrolled scheme, so a persona typically has both its primary scheme and the opportunistic passkey-only scheme. It never sees:

  • The questions themselves
  • The answers
  • Individual leaf hashes
  • The Merkle tree or paths
  • Any salts
  • The sub-factor commitments (question_root or passkey_commitment) separately

The single aggregate is deliberate: keeping sub-factor commitments off the server prevents lower-entropy or human-derived commitments from becoming separately addressable, indexable, or queryable. New scheme versions get new aggregate formulas — there's no preserved backward compatibility for individually persisted sub-factor commitments.

Agent keypair scheme

Agent personas (isHuman: false) don't run the WebAuthn ceremony at all — they enroll agent_keypair_v1, which attests to a raw ECDSA-P256 keypair the agent controls. There's no browser passkey UI involved; the agent (or its host application) holds the private key and signs the server's challenge bytes directly. Custody of that keypair is the agent operator's responsibility — AuthenSee only verifies control of it.

Check enrolled factors

getEnrolledFactors() and updateFactor() are reserved for a future proof-gated rotation flow and currently throw — the server stores one opaque aggregate commitment per enrolled scheme, not addressable per-factor records (see the API reference), so there's nothing for either call to read or replace today.

Adding a factor (policy-upgrade ceremony)

To add a new scheme to an already-enrolled persona — most commonly to satisfy a provider's current policy after enrolling under an older one — the SDK proves the persona's existing scheme once and binds the new scheme's commitment into that same proof, atomically. This is what powers the guided upgrade ceremony described in Provider policy and is submitted to POST /v1/enrollments/add, not /v1/enrollments.

const result = await AuthenSee.upgradeScheme({
  discovery: { /* proof of the persona's existing scheme */ },
  newScheme: 'passkey_only_v1',
  newFactor: { /* new scheme's factor data, if any */ },
});
// Adds the new enrollment; does NOT log the persona in.
// Call authenticate() again afterward to complete the login.

Cross-provider reusability

A user who enrolled via provider A can reuse that persona at provider B without re-enrolling — they prove their existing policy factor and the persona is linked to provider B. See Linking an existing persona. If provider B's policy requires a scheme the persona hasn't enrolled at all, the SDK detects that on authenticate() and can offer an enrollment flow inline instead.

Next

On this page