AuthenSeeDocs

Security Model

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

Security 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 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 encrypted enrollment blob
Raw answersUser's plaintext responsesTransient — in memory only during enrolment/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, malicious browser extension, shared device, 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), which is 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 rootsSingle field element per factor per user
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 integrators see)

AccessibleNOT accessible
Verification result (boolean) + JWT tokenRaw answers or questions
Persona ID (opaque)Salts or private circuit inputs
Persona type (human/agent)Witness data
Proof metadata (timestamp, validity window)Any factor-specific secrets

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 rate limiting

Limits the total number of authentication attempts from a single IP address across all personas. Prevents distributed attacks against a single persona and single-IP attacks against multiple personas.

Per-provider rate limiting

Limits the total authentication volume for a single provider (default: 100 req/sec, configurable up to plan limit). Prevents a compromised provider API key from being used to probe the system.

Per-persona rate limiting

For human personas, AuthenSee does not enforce per-persona rate limiting at the platform level. Per-persona rate limiting creates a denial-of-service risk: an attacker can lock out a legitimate user by deliberately failing authentication attempts. Per-persona rate limiting for humans (if desired) is the provider's responsibility to implement in their own application layer.

Agent personas are different: an agent is automation acting under the provider's policy, so a throttle is a feature rather than a lockout risk. Providers configure a per-agent budget (10–120 requests per minute, default 30) on the dashboard's Policy screen, and the platform enforces it per provider + persona at challenge-binding time. Attempts past the budget receive 429 RATE_LIMITED.

Provider policy

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

  • Factor combination — providers choose from a curated menu (for example passkey + image points). The chosen combination restricts which enrollment schemes new users may register. Enforcement happens at enrollment time: tightening the policy never locks out already-enrolled users, whose enrollments keep working under the scheme they registered with.
  • Agent access — providers may 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.

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 — so 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)

The server generates a provider-specific challenge for every authentication request. It includes provider identity metadata (provider ID, domain, timestamp, nonce), binding each proof to a specific provider. A proof generated for Provider A cannot be replayed against Provider B.

External parameter (optional)

Providers may supply their own nonce/challenge that gets included in the proof's public inputs. This is useful for binding authentication to a specific action or session (e.g., a transaction ID or 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 — a root client provider keeps it for the lifetime of the flow. There is no session cookie anywhere. The browser obtains the token by redeeming a one-time flowCode (the only thing that ever travels in the URL) via an internal redeem endpoint; the token is then 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 — which is also why the hosted flow denies framing rather than trying to support embedding. The iframe mount() path exists but is authentication-only and cannot run enrollment or recovery.

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 proof
Device theftPasskey in secure enclave + factor threshold (attacker needs 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