How to Use Zero-Knowledge Proofs for Private Authentication in Web Apps

How to Use Zero-Knowledge Proofs for Private Authentication in Web Apps

A login form asks for a password, sends it over TLS, and the server compares it to a stored hash. If the database leaks, that hash becomes an offline cracking target, and anyone who reused the password is exposed elsewhere. Zero-knowledge proofs change the shape of the exchange: the browser proves it can satisfy a statement — "I know the private key behind this public key" — without sending the key or a reusable derivative of it.

What follows is the practical version for a web app: key generation at signup, a challenge-response login, server-side verification, and the mistakes that quietly undo the privacy you were trying to get.

What you are actually building

Strip away the cryptography and the flow looks ordinary. At registration the client generates a keypair and sends only the public half. At login the server sends a random challenge, the client uses the private key to produce a proof, and the server checks that proof against the stored public key. If it verifies, you issue a session cookie like any other login.

The privacy gain is narrow but real: no password crosses the wire, no password hash sits in your database, and a proof captured from one session is useless in the next. That last property is what separates this from simply posting a static token. The value the client sends is bound to a fresh, server-chosen nonce.

It helps to know that familiar schemes already fit this description. A Schnorr signature is a non-interactive zero-knowledge proof of knowledge of a discrete logarithm; Fiat–Shamir turns an interactive exchange into a single message by hashing the commitment and the context into the challenge. So "using ZK for login" usually means using a well-tested signature scheme correctly, not inventing a circuit.

Choosing the right primitive

Two families cover most authentication needs, and their costs differ sharply.

  • Sigma protocols and signatures — Schnorr over Ed25519 or secp256k1, or EdDSA in practice. Fast, small, well reviewed. Browser libraries such as @noble/curves or libsodium turn this into a few dozen lines of code. This is the right starting point for passwordless login.
  • General-purpose circuits — circom with Groth16 or PLONK, Noir with Barretenberg, Halo2. Needed when the statement is richer than "I hold this key": proving membership in a set, proving an attribute crosses a threshold, proving an unspent note. Proving in the browser can take seconds and demands careful circuit design.

If all you want is passwordless, WebAuthn and passkeys already give you hardware-backed keypairs and a browser-mediated ceremony. Adopt them unless you have a specific reason not to. Circuits enter the picture when the verifier must learn nothing beyond a claim, or when you cannot depend on platform authenticators.

Registration, step by step

  1. Generate the keypair in the browser with a vetted library. Never hand-roll group arithmetic.
  2. Persist the private key somewhere durable: an encrypted store, an OS keychain, or a passkey-style credential. In-memory-only keys force a re-registration on every page load.
  3. Send the public key and a proof binding it to the user record, over TLS.
  4. Verify the account before trusting the key — email loop, existing session, or an invite code. Otherwise anyone can attach a public key to someone else's identifier.
  5. Store the public key and a key identifier. There is no password hash to keep, and no reset secret to leak.

The login exchange

The server starts by issuing a nonce and remembering it — in a signed cookie, a short-lived cache entry, or a database row. It must be unpredictable, single-use, and expire within a minute or two.

The client then produces a proof bound to that nonce and to the relying origin:

Commitment: R = r·G. Challenge: c = H(R, publicKey, nonce, origin). Response: s = r + c·x. Server check: s·G == R + c·publicKey.

A library will handle the hash-to-scalar step and the encoding for you. What matters is what goes into that hash. Include the origin and any session context, so a proof generated for one site or one flow cannot be replayed in another. Domain separation is not optional.

The server verifies the proof, marks the nonce as spent, and issues a normal session token. From that point on, your authorization code does not need to know a zero-knowledge proof was involved.

Where the real privacy wins are

Passwordless login is the easy case. The more interesting problems appear when the verifier must not learn the underlying fact:

  • Prove membership in an allowlist without revealing which member you are.
  • Prove an attribute — age over a threshold, a credential from an issuer — without handing over the document.
  • Prove ownership of a token or a note without linking it to your identity.
  • Prove that a computation ran correctly on private inputs, for an audit or a compliance check.

Each of these needs a circuit, a setup decision for some proving systems, and a verification key deployed next to your backend. That is real work. Budget for it instead of promising it inside a sprint.

Mistakes that break the privacy

  • Sending the private key or a raw identifier derived from it to the server "just for logging." One line like that can undo everything.
  • Reusing a nonce. With Schnorr-style proofs, nonce reuse lets an observer solve for the private key outright.
  • Hashing only the commitment into the challenge and dropping the public key from the context.
  • Treating the public key as anonymous. It is a stable identifier; issue a separate key per device or per app if linkability matters to your users.
  • Skipping recovery. People lose keys, and a support path that bypasses the proof becomes the weakest link in the whole design.

A sensible first sprint

Start with passwordless login built on a standard signature scheme. Add recovery codes and a second device during registration, since key loss is the failure mode users actually hit. Write tests for replayed proofs, expired nonces, and proofs generated for the wrong origin — those three cases catch most real bugs. Keep the private key on the client, keep the public key on the server, and log nothing that identifies the credential. Later, when you have one specific privacy claim to make, add a circuit for that claim alone. That order ships the easy win now and leaves the harder cryptography the review time it deserves.

Photo: carlosalbertoteixeira / Pixabay

Related Posts