AuthenSeeDocs

Security model

Trust boundaries, exposure-resilient architecture, replay prevention, provider policy, and the threat model.

AuthenSee is designed with the assumption that all stored data could be exposed. Security comes from cryptographic guarantees, not from secrecy of stored data. There are no password hashes to crack, no biometric templates to steal, no shared secrets between client and server.

Exposure-resilient architecture

If an attacker obtains a full database dump, they get:

What they getWhy it is useless
Merkle rootsCannot reverse-engineer answers from a Poseidon2 hash commitment
Spent nullifiersCannot be reused (replay prevention); cannot be linked to answers
Proof metadataOnly prover version and timestamps — no actual proof data stored
Provider API keysRevocable; scoped to specific operations

Full proofs are not stored on the server. Only metadata (prover version, timestamps) is retained for analytics. This eliminates residual risk if a prover implementation is later found to have a vulnerability.

Trust boundaries

Client-side boundary (never leaves the device)

DataDescriptionLifetime
Raw questionsThe questions/prompts themselvesStored locally in the encrypted enrollment blob
Raw answersThe user's plaintext responsesTransient — in memory only during enrollment/auth
Answer hashesPoseidon2 hashes of the answersTransient — exist only in local scope while the Merkle tree is built; never persisted
SaltsDerived deterministically from persona ID + question indexPersisted in sessionStorage (witness data, not a secret)
Question Merkle path + rootTree witness needed to prove membershipPersisted in sessionStorage
Passkey private keysFIDO2/WebAuthn private key materialPlatform secure storage (Keychain / Keystore / TPM)
Witness dataIntermediate values from the circuit solverTransient — exists only inside the WASM/native prover

The answer hash is deliberately not persisted. If it were, anyone with read access to sessionStorage (XSS, a malicious browser extension, a shared device, an exfiltrated session backup) could feed the stored hash directly into the proof witness and replay authentication without typing the answer. By keeping only the Merkle path + salt + root on-device, the SDK forces the prover to reconstruct the leaf from a freshly typed answer at every authentication. The circuit's Merkle assertion is what enforces the knowledge factor — recovering a valid answer_hash from (questionRoot, salt, path) requires inverting Poseidon2, which is computationally infeasible.

The only value persisted to the server is the aggregate scheme commitment (auth_commitment) — one field element binding the question root and the passkey commitment together.

Server-side boundary (what the server stores)

The server is deliberately "answer-blind." It can verify proofs but cannot reconstruct, guess, or correlate answers.

DataDescription
Merkle rootsA single field element per enrolled scheme per persona
Proof metadataProver version, timestamps, verification result (analytics only)
NullifiersPoseidon2(salt, challengeId) — prevents double-use of proofs
Provider configsAPI keys, auth policies, branding
Persona IDsOpaque identifiers. Type: human or agent. Optional email for recovery.

No PII is stored by default. Persona IDs are opaque. The server learns nothing about users beyond the fact that they hold valid proofs.

Provider boundary (what you see)

Accessible to youNot accessible
Verification result + signed JWTRaw answers or questions
providerSubject (opaque, provider-scoped)Salts or private circuit inputs
Persona type (human/agent)Witness data
Proof metadata (timestamp, validity window)Any factor-specific secrets
Raw persona ID or the user's activity at other providers

Two providers receive different providerSubject values for the same persona, so users cannot be correlated across providers by comparing subjects.

SDK boundary layers

+---------------------------------------------------------------+
|                        UI components                          |
|  Receives: factor prompts, validation state, result boolean   |
+-------------------------------+-------------------------------+
                                |
                      user input (raw answers)
                                |
                                v
+---------------------------------------------------------------+
|                     Core orchestration                        |
|  Computes: answer hashes, salts, Merkle tree, circuit inputs  |
|  Holds in memory: hashes, salts (transient)                   |
+------------------+--------------------+-----------------------+
                   |                    |
         hashes, salts           circuit inputs
                   |                    |
                   v                    v
+---------------------+   +----------------------------+
| Crypto primitives   |   |    Native Rust prover      |
| Poseidon2, Merkle   |   |    (uniffi/barretenberg)   |
| Pure functions,     |   |    Holds: witness (trans.) |
| no state            |   |    Outputs: proof + pubIn  |
+---------------------+   +-------------+--------------+
                                         |
                              proof + publicInputs
                              (all private data gone)
                                         |
                                         v
                          +--------------+---------------+
                          |     Network boundary         |
                          |  POST /v1/verify             |
                          |  { personaId, challengeId,   |
                          |    proof, publicInputs }     |
                          +------------------------------+

Everything above the network boundary runs on the client. The only values that cross the boundary are the ZK proof and its public inputs, which by construction reveal nothing about the private inputs.

Anti-bruteforce protection

AuthenSee implements layered rate limiting. All rate limits are platform-enforced and cannot be disabled by providers.

  • Per-IP — limits total authentication attempts from a single IP address across all personas. Prevents both distributed attacks against one persona and single-IP attacks against many.
  • Per-provider — limits the total request volume for a single provider (default: 1,000 requests per minute, configurable per provider). Prevents a compromised provider API key from being used to probe the system.
  • Per-persona — for human personas, AuthenSee deliberately does not enforce per-persona rate limiting at the platform level: it would create a denial-of-service vector, letting an attacker lock out a legitimate user by deliberately failing attempts. If you want per-account throttling for humans, implement it in your own application layer. Agent personas are different: an agent is automation acting under your policy, so a throttle is a feature rather than a lockout risk. You configure a per-agent budget (10–120 requests per minute, default 30) on your dashboard's Policy screen; the platform enforces it per provider + persona at challenge-binding time, and attempts past the budget receive 429 RATE_LIMITED.

Provider policy

You configure an authentication policy on your dashboard, and the auth server enforces it at request time — changes apply immediately, because policy is read fresh on every request:

  • Factor combination — one of a curated menu: passkey_and_image_points (default), passkey_only, or passkey_and_behavior (motion gesture). It restricts which schemes new users may enroll and which scheme a returning user must prove to log in.
  • Agent access — allow or block agent personas entirely. When blocked, an agent persona is rejected with 403 FORBIDDEN at enrollment, at challenge binding, and at proof verification. Human personas are never affected.
  • Agent rate limit — the per-agent budget described above.

Policy changes and the login upgrade ceremony

Tightening your factor combination doesn't delete anyone's enrollment, but it does change what counts as a valid login going forward: at login (not at enrollment or any other action), the auth server requires the proven scheme to satisfy your current policy, not whatever policy was active when the user first enrolled.

A user whose only matching enrollment is now off-policy hits 409 policy_upgrade_required instead of being locked out. The error body carries your current factorCombination and the allowedSchemes it maps to, which the hosted flow uses to drive a guided upgrade: the user proves the factor they already have (any enrolled scheme — commonly the passkey-only factor every persona gets opportunistically at enrollment), and that proof authorizes adding the new, on-policy scheme in the same step. Their next login succeeds normally. The ceremony never requires re-proving knowledge the user has already demonstrated, and it never silently downgrades or drops the old enrollment.

Everything that isn't a login — enrollment itself, adding a factor, linking a persona to a new provider, updating a factor, or unlinking — proves control of whichever scheme the caller already holds and is not gated on the current policy the same way. See the upgrade ceremony for the API mechanics.

Replay prevention

Each proof includes a nullifier — a deterministic, unlinkable value derived from the user's salt and the challenge:

nullifier = Poseidon2(salt, challengeId)

The server records each nullifier the first time it sees it and atomically rejects any proof whose nullifier has already been used — a captured proof can never be replayed. The claim is enforced transactionally with a uniqueness guarantee, so even concurrent submissions of the same proof resolve to exactly one accepted use.

Challenge parameters

The ZK proof includes two types of challenge parameters:

proof_public_inputs = {
  merkleRoot,
  nullifier,
  providerChallenge,       // server-generated, always present
  providerExternalParam?   // optional, provider-supplied
}
  • Provider-specific challenge (mandatory) — generated by the server for every authentication request, carrying provider identity metadata (provider ID, domain, timestamp, nonce). It binds each proof to one provider: a proof generated for provider A cannot be replayed against provider B.
  • External parameter (optional) — a nonce or challenge you supply, included in the proof's public inputs. Useful for binding authentication to a specific action (a transaction ID, a session nonce).

Hosted flow surface security

The AuthenSee hosted pages — the surface behind hosted-page redirects and the popup drop-in — are hardened so the session token never becomes a portable, stealable artifact.

The hosted flow holds the session token in memory only for the lifetime of the flow. There is no session cookie anywhere. The browser obtains the token by redeeming a single-use flowCode (the only thing that ever travels in the URL); the token is never written to a cookie, never placed in a URL, and never surfaced outside the in-memory store. SDK calls reach the auth server through a same-origin proxy that attaches the Authorization: Bearer header server-side.

Consequences:

  • The flowCode is single-use — a mid-flow full-page refresh restarts the flow because the code can't be redeemed twice
  • With no cookie, there is no ambient credential to leak via CSRF or cookie theft, and the flow runs correctly in any top-level context, including a popup

Framing denial (anti-clickjacking)

The hosted flow refuses to be framed: it sends Content-Security-Policy: frame-ancestors 'none' together with X-Frame-Options: DENY. A hostile site cannot embed the pages in an invisible iframe to overlay or hijack clicks.

Why the WebAuthn ceremony runs on AuthenSee's origin, in a popup

The passkey ceremony — navigator.credentials.create() (registration) and discoverable get() — must run in a top-level browsing context on AuthenSee's own origin. Cross-origin iframes block or restrict these calls (Safari blocks them; Chrome only allows create() since v119 with a permission policy), and the passkey's Relying Party ID is bound to AuthenSee's domain so a persona's passkey stays reusable across providers. The drop-in therefore launches the flow in a popup — a top-level window — rather than an inline iframe. And since frame-ancestors 'none' applies to every route, there is no iframe-embeddable integration surface at all: use the popup or a full-page redirect.

Threat model

Security invariants

  1. The server never learns answers. Not through storage, not through logs, not through side channels.
  2. A full database dump reveals nothing exploitable. Only Merkle roots, nullifiers, and proof metadata are stored.
  3. Proofs cannot be replayed. Nullifiers are deterministic and single-use.
  4. Cross-domain attacks are prevented via mandatory provider-specific challenges.
  5. Rate limiting prevents online brute-force at the IP and provider level, plus per-persona budgets for agent personas.

Mitigations by attack vector

Attack vectorMitigation
Brute-force answersPer-IP rate limiting + Poseidon2 preimage resistance + multi-factor threshold
Runaway or abusive automationProvider-configured agent policy: block agents outright, or cap each agent at a per-minute budget
Rainbow table attackPer-persona salts prevent cross-persona answer correlation
Server breachExposure-resilient architecture — nothing exploitable is stored
Proof replayDeterministic nullifiers, single-use enforcement
Cross-domain hijackingServer-generated provider-specific challenge in every proof
Device theftPasskey in secure enclave + factor threshold (an attacker needs the knowledge factors too)
Compromised proverFull proofs not stored on server; only metadata retained
Session token theft (hosted flow)Cookie-free, in-memory token; only a single-use flowCode travels in the URL
Clickjacking the hosted flowframe-ancestors 'none' + X-Frame-Options: DENY; passkey ceremony runs top-level in a popup

Next