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 get | Why it is useless |
|---|---|
| Merkle roots | Cannot reverse-engineer answers from a Poseidon2 hash commitment |
| Spent nullifiers | Cannot be reused (replay prevention); cannot be linked to answers |
| Proof metadata | Only prover version and timestamps -- no actual proof data stored |
| Provider API keys | Revocable; 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)
| Data | Description | Lifetime |
|---|---|---|
| Raw questions | The questions/prompts themselves | Stored locally in encrypted enrollment blob |
| Raw answers | User's plaintext responses | Transient — in memory only during enrolment/auth |
| Answer hashes | Poseidon2 hashes of the answers | Transient — exist only in local scope while the Merkle tree is built; never persisted |
| Salts | Derived deterministically from persona ID + question index | Persisted in sessionStorage (witness data, not a secret) |
| Question Merkle path + root | Tree witness needed to prove membership | Persisted in sessionStorage |
| Passkey private keys | FIDO2/WebAuthn private key material | Platform secure storage (Keychain / Keystore / TPM) |
| Witness data | Intermediate values from the circuit solver | Transient — 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.
| Data | Description |
|---|---|
| Merkle roots | Single field element per factor per user |
| Proof metadata | Prover version, timestamps, verification result (analytics only) |
| Nullifiers | Poseidon2(salt, challengeId) -- prevents double-use of proofs |
| Provider configs | API keys, auth policies, branding |
| Persona IDs | Opaque 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)
| Accessible | NOT accessible |
|---|---|
| Verification result (boolean) + JWT token | Raw 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
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 FORBIDDENat 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:
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:
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.
Cookie-free, in-memory session token
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
flowCodeis 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
- The server never learns answers. Not through storage, not through logs, not through side channels.
- A full database dump reveals nothing exploitable. Only Merkle roots, nullifiers, and proof metadata are stored.
- Proofs cannot be replayed. Nullifiers are deterministic and single-use.
- Cross-domain attacks are prevented via mandatory provider-specific challenges.
- Rate limiting prevents online brute-force at the IP and provider level, plus per-persona budgets for agent personas.
Mitigations by attack vector
| Attack vector | Mitigation |
|---|---|
| Brute-force answers | Per-IP rate limiting + Poseidon2 preimage resistance + multi-factor threshold |
| Runaway or abusive automation | Provider-configured agent policy: block agents outright, or cap each agent at a per-minute budget |
| Rainbow table attack | Per-persona salts prevent cross-persona answer correlation |
| Server breach | Exposure-resilient architecture -- nothing exploitable is stored |
| Proof replay | Deterministic nullifiers, single-use enforcement |
| Cross-domain hijacking | Server-generated provider-specific challenge in proof |
| Device theft | Passkey in secure enclave + factor threshold (attacker needs knowledge factors too) |
| Compromised prover | Full 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 flow | frame-ancestors 'none' + X-Frame-Options: DENY; passkey ceremony runs top-level in a popup |