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 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 residual 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 the encrypted enrollment blob |
| Raw answers | The user's plaintext responses | Transient — in memory only during enrollment/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, 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.
| Data | Description |
|---|---|
| Merkle roots | A single field element per enrolled scheme per persona |
| 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 you see)
| Accessible to you | Not accessible |
|---|---|
| Verification result + signed JWT | Raw 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
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, orpasskey_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 FORBIDDENat 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:
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:
- 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.
Cookie-free, in-memory session token
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
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. 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
- 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 every proof |
| Device theft | Passkey in secure enclave + factor threshold (an attacker needs the 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 |
Next
- Core concepts — the primitives behind these guarantees
- Enrollment & verification reference — every check the verifier runs
- Hosted pages guide — the hardened surface in practice