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.
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.
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:
- Normalize answers -- Answers are lowercased, trimmed, and normalized to ensure consistent hashing.
- 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. - Build a Merkle tree -- The leaf hashes are assembled into a Merkle tree using Poseidon2.
- Extract the root -- The Merkle root is a single field element that commits to all answers without revealing any of them.
- Store witness locally -- The SDK persists the Merkle path, salt, and the question Merkle root in
sessionStorage(under theauthensee:enrollment:v2:prefix), alongside the V1 passkey witness. The answer hash itself is never persisted -- if it were, anyone with read access tosessionStoragecould 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:
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_rootorpasskey_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
Update existing factors
To change security questions after initial enrollment:
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.