ZK-Auth uses a full STARK proof system to let a client authenticate to a server without ever transmitting — or even computing — a password hash the server could misuse. The server learns only that you know the correct password. Nothing more.
In every authentication flow, the following invariants hold unconditionally.
| Item | Transmitted to server? | How |
|---|---|---|
| Raw password | ✗ Never | Stays in browser memory; never serialised or sent |
| Argon2id hash output (h₁) | ✗ Never (Pattern 2) | Used only as circuit witness; not included in proof |
| Server pepper | ✗ Never | Stored in server env / HSM; client never sees it |
| Commitment C = Poseidon(h₁, pepper) | Server computes at registration | Client sends mimc_output; server applies pepper itself |
| STARK proof | ✓ Sent | Proves correct execution; reveals no witness values |
| Argon2id salt & params | ✓ Public | Stored in DB; returned to client on login so it can re-derive h₁ |
The system supports three auth patterns with different security / complexity trade-offs. Pattern 2 is the primary focus — it is the only mode where the server pepper is completely opaque to the client.
A classical bcrypt or Argon2id hash is computed client-side and sent to the server. No zero-knowledge proof. Included for baseline comparison only.
Client computes C = Poseidon(h₁, serverPepper) and includes it in the
proof's public inputs. Server re-computes and compares. Requires the pepper to be
available in the client execution context.
The client generates a STARK proof with no pepper computation at all.
After the server verifies the proof, it independently computes
Poseidon(mimc_output, serverPepper) and compares against the stored
commitment. The pepper is an opaque server secret the client never observes.
No external KDF. The password itself is encoded as the circuit witness via
hashBytesToWitness. Useful for constrained environments but lacks
Argon2id's memory-hardness against offline attacks.
The cryptographic foundation. Provides finite-field arithmetic over the BN254
scalar field (p ≈ 2¹⁵⁴), the Number Theoretic Transform (NTT),
the friFold function, Merkle trees, the Poseidon-2 hash, and
the Fiat-Shamir Transcript sponge with integrated PoW grinding.
Exported as window.StarkMath in browsers and
module.exports in Node.js. Used by both prover and verifier.
Runs entirely in the browser. Accepts a password and server challenge, runs Argon2id (WASM), derives the circuit witness, builds the MiMC-7 execution trace, performs the Low-Degree Extension, constructs the quotient polynomial, commits via Merkle trees, runs FRI, grinds PoW, and returns the complete proof object.
In Pattern 2 mode (algo: 'argon2id-p2'), pepper_commit
is deliberately omitted from public_inputs.
Runs on the server (Node.js). Re-derives mimcKey from the proof
nonce and server secret, replays the Fiat-Shamir transcript, checks all Merkle
inclusion proofs, enforces AIR constraints at each query index, checks FRI
consistency across all folds, and validates the PoW nonce.
Exposes verifyPepperCommitP2(mimc_output, storedC) separately —
called by the routing layer after verify() succeeds in Pattern 2.
Uses timing-safe comparison for all commitment checks.
Demonstrates the full Pattern 2 registration and login flow. Fetches a server
challenge, runs Argon2id in-browser via WASM, generates a STARK proof, and
POSTs to /api/p2/register or /api/p2/login. Displays
live step-by-step progress with success/error states.
Complete specification of the field, hash functions, cipher, proof system parameters, and security argument for the ZK-Auth STARK construction.
All arithmetic is performed modulo the BN254 scalar field prime.
Used for the Fiat-Shamir transcript sponge, the KDF chain commitment, Merkle tree leaf/node hashing, the round-constant commitment, and the pepper commitment.
Argon2id is the OWASP-recommended password hashing function. It is memory-hard, defeating GPU and ASIC brute-force attacks. The following parameter floor is enforced by both the prover and verifier.
encodedStr changes h₁, which changes the transcript nonce, which
invalidates every Merkle path and FRI check in the proof.
MiMC is a block cipher designed for ZK-friendliness — its step function is a single low-degree polynomial over the field (degree 7 with the x⁷ exponent). This makes it directly expressible as an Algebraic Intermediate Representation (AIR) constraint.
With 220 rounds, MiMC provides a ×5 safety margin over the minimum of ⌈log₇(p)⌉ ≈ 40 rounds required to resist interpolation attacks, contributing an estimated 25–45 additional bits of concrete security.
The STARK proof encodes the MiMC execution trace as a polynomial, proves it satisfies the MiMC AIR constraint everywhere on the trace domain, then uses FRI to prove the resulting quotient polynomial is low-degree.
Both prover and verifier must absorb values in exactly this order. Deviations cause all challenge values to diverge, making every check fail.
| Component | Contribution | Bits |
|---|---|---|
| FRI query soundness | Per-query error ≤ 2⁻⁵ (ρ = 1/32); 35 queries → 2⁻¹⁷⁵ | 175 |
| Fiat-Shamir PoW grinding | 2²⁴ SHA-256 work per forged proof attempt | +24 |
| MiMC-7 algebraic security | 220 rounds vs. ⌈log₇(p)⌉ ≈ 40 minimum → ×5 safety margin | +25–45 |
| Argon2id memory-hardness | 64 MB RAM required per hash → ASIC/GPU brute-force infeasible | KDF layer |
| Timing-safe comparisons | All commitment checks use crypto.timingSafeEqual (Phase 3) | Side-channel |
| Session replay prevention | server_challenge absorbed first; each proof bound to one login | Protocol |
Step-by-step walkthrough of Pattern 2 — the primary auth mode — showing exactly how a user's password is transformed into a stored commitment the server can verify, without the password or Argon2id output ever leaving the client.
C = Poseidon(mimc_output, serverPepper).
The client proves (via STARK) that it knows a password whose Argon2id output, when run through
MiMC-7, produces mimc_output. The server then applies the pepper itself — the client
never computes or sees C.
The user types their username and password into the browser. The password never leaves JavaScript memory — it is never serialised, logged, or sent over the network at any point in this flow.
The client calls GET /api/nonce. The server returns a freshly
generated 256-bit cryptographically random hex string. This nonce is absorbed
first into the Fiat-Shamir transcript, binding the entire proof to this specific
login session. Replaying the proof against a different session is impossible.
The client generates a 16-byte random salt using crypto.getRandomValues.
Argon2id is then run in-browser via WebAssembly with parameters at or above the
enforced floor (t ≥ 3, m ≥ 65 536 KB). The result is the full encoded string —
including the parameters and base64-encoded hash — which is used as the circuit witness.
The MiMC encryption key is not fixed — it is derived fresh for each session using HMAC-SHA256. The key material depends on both the hash witness (nonce) and the server secret, making it unpredictable to an attacker who does not know the server secret. The server independently re-derives this key during verification.
The prover builds a 256-row execution trace. The first row is the Argon2id
witness h₁. Each subsequent row applies one MiMC-7 step: add the
key and round constant, then raise to the 7th power. This is the computation
the STARK proof will certify.
The 256-row trace is interpolated into a polynomial via inverse NTT, then evaluated at 8 192 points (blowup factor 32). This "low-degree extension" is committed to with a Merkle tree. The same is done for the quotient polynomial and the KDF trace.
The Fast Reed-Solomon IOP (FRI) protocol interactively reduces the claim that the combined polynomial is low-degree. Each fold halves the domain size (8192 → 4096 → … → 16). Random folding challenges are drawn from the transcript after each Merkle commitment, making the protocol non-interactive (Fiat-Shamir).
After all commitments are absorbed, the prover must find a 32-bit nonce N such that the first 24 bits of SHA-256(transcriptState ‖ N) are zero. This requires ~16 million SHA-256 evaluations on average, making it computationally expensive to forge transcripts by trying different commitment sequences.
The verifier challenges the prover with 35 random indices (drawn from the transcript). At each index, the prover opens the trace polynomial, the quotient polynomial, and all FRI layers with Merkle inclusion proofs. These openings form the bulk of the proof blob.
The complete proof is POSTed to /api/p2/register. Crucially, in Pattern 2,
pepper_commit is absent from public_inputs.
The client never computed it. mimc_output is present as a public output
of the circuit.
The server's ZKVerifier.verify(proof, {skipPepperCheck: true})
performs a complete STARK verification: re-derives mimcKey, replays the transcript,
checks all Merkle paths, verifies the AIR constraint at all 35 query indices,
checks FRI consistency across all 9 folds, and validates the PoW nonce.
After proof verification succeeds, the routing layer calls
verifyPepperCommitP2. During registration, the server
computes C and stores it alongside the username and Argon2id salt.
The pepper is read from the server environment (HSM in production).
The client never sees this value.
C. To reverse it they need both
mimc_output (requires running MiMC on Argon2id output) and the pepper
(server secret). Brute-forcing requires running Argon2id + MiMC-7 per password guess.
h₁ from the same password.
Identical to registration: the client calls GET /api/nonce and receives
a fresh 256-bit challenge. This is the first value absorbed into the Fiat-Shamir
transcript, preventing replay of a previously valid proof.
The client calls GET /api/p2/salt/:username. The server returns
the Argon2id salt and parameters that were stored at registration. The salt is
public — knowing it without the password yields nothing.
Argon2id is a deterministic function: given the same password, salt, and parameters,
it always produces the same output. The client re-runs Argon2id with the retrieved salt
to reproduce the exact h₁ that was used during registration.
Steps 4–9 of registration repeat identically: derive mimcKey, build MiMC trace,
LDE, Merkle trees, FRI folds, PoW grinding, and query openings. The resulting
mimc_output = trace[220] will match the registration value only
if h₁ is the same — which happens only if the password is correct.
The server runs the full STARK verifier (verify(proof, {skipPepperCheck: true})),
confirming that the proof is structurally valid and internally consistent. This proves the
client ran Argon2id and MiMC correctly, producing the stated mimc_output.
After the STARK is verified, verifyPepperCommitP2(mimc_output, storedC)
is called. The server recomputes Poseidon(mimc_output, serverPepper) and
compares it — using timing-safe equality — to the stored commitment C. If they match,
authentication succeeds.
mimc_output that differs from the
registered value. The pepper check will fail. The STARK proof and the pepper check are
independent security layers — both must pass.