All posts

Cryptography / July 5, 2026

Authentication that never sees the password

How ZKAuth verifies a login with a zero-knowledge proof instead of a password, and what that changes about the worst day of running an auth service.

Mohith · 6 min read

A ZKAuth login screen sending proof material to a verified security boundary.

The worst day of an authentication vendor’s life is the day their credential database leaks. Every design decision downstream of that day is damage control: hash the passwords harder, salt them better, rotate faster, detect sooner. ZKAuth starts from a different question: what if the server never had the password, in any form, at any point?

That is not a slogan; it is a wire format. Here is the entire request body the ZKAuth engine receives when someone registers:

POST /api/v1/auth/registerjson
{  "email": "ada@example.com",  "salt": "0x8f3a...",  "commitment": "0x2c17...",  "deviceInfo": { "deviceName": "Chrome on Mac", "deviceType": "desktop" }}

No password field. Before the request leaves the device, the zkauth-clientSDK stretches the password with Argon2id, then binds the result to the user’s salt, the project, and the email with a Poseidon hash. The output is a commitment: a number the server can store that reveals nothing usable about the password that produced it.

Logging in is proving, not sending

At login, the SDK re-derives the same material locally and generates a Groth16 zero-knowledge proof: a few hundred bytes that say I know a password whose commitment matches the one you stored for this email, in this project, without revealing the password or even its hash. The login request looks like this:

POST /api/v1/auth/loginjson
{  "email": "ada@example.com",  "proof": { "pi_a": ["..."], "pi_b": [["..."]], "pi_c": ["..."] },  "publicSignals": ["...commitment", "...clientHash", "...emailHash"],  "proofNonce": "...",  "proofTimestamp": 1783140000000,  "deviceInfo": { "deviceName": "Chrome on Mac", "deviceType": "desktop" }}

The engine then verifies in a strict order: proof structure, then timestamp freshness, then that the public signals bind the exact commitment, tenant, and email being claimed, then the Groth16 verification itself, and finally a single-use replay check so the same proof can never authenticate twice. Fail any step and the login is rejected.

What this changes in practice

  • A database leak does not leak credentials. Commitments cannot be replayed as logins, because every proof is single-use and freshness-checked.
  • The proof is tenant-bound. A valid proof for one project cannot be replayed against another; the tenant hash is a public input to the circuit itself.
  • Intercepting the server request is less useful. There is no password in transit to steal from the ZKAuth request; device malware and fake login pages are still separate risks.

What we do not claim

Honesty is a feature here, so the boundaries matter as much as the pitch. Malware on the user’s device is out of scope: if the device is compromised, the password can be captured at the keyboard before any cryptography runs. Groth16 over BN128 is not post-quantum. And our proving keys currently come from a single-party setup; a multi-party ceremony is planned and documented before we would ever claim otherwise. The complete list lives on our limitations page, which is checked in CI against the code.

If you want to see the flow end to end, the quick start gets you from an empty project to a verified proof-based login in a few minutes.

Mohith

Founder, ZKAuth

July 5, 2026

Next post

Hosted auth with hard edges