AuthenSeeDocs

Enrollment

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

Enrollment

Enrollment is the process of registering a user's authentication factors with AuthenSee. The user answers security questions on their device, and only a cryptographic commitment (the Merkle root) is sent to the server. Questions and answers 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 security question + passkey (V1, default for humans)

The V1 flow registers an aggregate auth_commitment over (question_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
const result = await AuthenSee.enroll('security_questions', {
  questions: [
    { text: 'If you had to name a future cat, what would you call it?', answer: 'Pixel' },
  ],
  passkey: {
    credentialId: passkey.credentialId,
    pubkeyX: passkey.pubkeyX,
    pubkeyY: passkey.pubkeyY,
    rpId: window.location.hostname,
  },
});
 
console.log('auth_commitment:', result.merkleRoot);

V1 is single-question by design — the passkey_question_v1 scheme aggregates exactly one answer + one passkey signature in the same proof. Multi-question schemes are on the roadmap as a future scheme/circuit pair, not as a backward-compatible knob.

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",
  "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). 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 passkey scheme

A passkey-only scheme for agent personas is on the roadmap. Today, V1 covers human authentication; agent flows reuse the same scheme model but with a different scheme ID once it ships.

Check enrolled factors

const factors = await AuthenSee.getEnrolledFactors();
 
for (const factor of factors) {
  console.log(factor.type);        // 'security_questions' or 'passkey'
  console.log(factor.enrolledAt);  // enrollment timestamp
  console.log(factor.lastUsedAt);  // last authentication timestamp
}

Update existing factors

To change security questions after initial enrollment:

const result = await AuthenSee.updateFactor('security_questions');
// Requires current authentication first
// Generates a new Merkle root and registers it with the server

After an update, the server stores the new Merkle root. Any proofs referencing the old root will fail with MERKLE_ROOT_STALE.

Cross-provider reusability

A user who enrolled via Provider A can authenticate with Provider B without re-enrollment, as long as the required factors are already enrolled. The SDK detects missing factors on authenticate() and can offer an enrollment flow inline.

On this page