Introduction
Imagine proving to a server that you know the correct password — without ever sending it, hashing it on the wire, or revealing anything about it. That’s not science fiction; it’s the everyday promise of Zero-Knowledge Proofs (ZKPs). In this article, we’ll build a small but complete authentication flow using ZK-SNARKs with Circom and snarkjs. By the end, you’ll have a working proof-of-concept where a user authenticates by proving they know a secret whose hash matches a public commitment.
Let’s dive in.
The Core Idea
At its heart, a ZK-SNARK lets a prover convince a verifier that a statement is true, without revealing why it’s true. In our case, the statement is:
“I know a secret
passwordand asaltsuch thatPoseidon(password, salt) == commitment.”
The server only stores commitment. The user never transmits the password. They transmit a proof — a few hundred bytes of cryptographic data — which the server verifies in milliseconds.
What the server learns
- ✅ That the user knows some valid
(password, salt)pair. - ❌ Nothing about the password, the salt, or their relationship.
This is fundamentally different from traditional authentication, where a leaked database or a man-in-the-middle can compromise secrets.
The Stack
We’ll use the most approachable ZK stack available today:
- Circom — a DSL for writing arithmetic circuits.
- snarkjs — JavaScript library for proving and verifying.
- circomlib — a library of common circuits, including Poseidon hash.
- Groth16 — the proving scheme (fast, small proofs, requires a trusted setup).
Install everything:
bash
npm install -g circom snarkjs npm install circomlib
Step 1: The Circuit
Our circuit, auth.circom, takes two private inputs (password, salt) and one public input (commitment). It constrains the Poseidon hash of the private inputs to equal the public commitment.
circom
pragma circom 2.0.0;
include "node_modules/circomlib/circuits/poseidon.circom";
template Auth() {
// Private inputs (the witness)
signal input password;
signal input salt;
// Public input (the commitment stored on the server)
signal input commitment;
component h = Poseidon(2);
h.inputs[0] <== password;
h.inputs[1] <== salt;
// Constraint: hash must match the public commitment
commitment === h.out;
}
component main = Auth();
That single line — commitment === h.out — is the entire statement being proven. Everything else is plumbing.
Why Poseidon?
Poseidon is a ZK-friendly hash function. Unlike SHA-256 or Keccak, it’s designed to be cheap inside arithmetic circuits, which means proofs are generated in seconds instead of minutes. It’s the de facto standard in the ZK ecosystem.
Step 2: Compile and Generate Keys
Compile the circuit into an R1CS (Rank-1 Constraint System) and a WASM witness generator:
circom auth.circom --r1cs --wasm --sym
Next, download a Powers of Tau file — a reusable, public ceremony output that lets anyone build a trusted setup on top of it:
wget https://hermez.s3-eu-west-1.amazonaws.com/powersOfTau28_hez_final_10.ptau
Then run the Groth16 setup:
snarkjs groth16 setup auth.r1cs powersOfTau28_hez_final_10.ptau auth_0000.zkey snarkjs zkey contribute auth_0000.zkey auth_final.zkey \ --name="dev-contribution" -v snarkjs zkey export verificationkey auth_final.zkey verification_key.json
⚠️ Production note: A real deployment requires a multi-party ceremony for phase 2, where multiple independent contributors add randomness. A single-contributor setup is fine for learning, but not for production.
Step 3: Registration
The user picks a password and salt, computes the commitment, and sends only the commitment to the server.
// register.js
const { buildPoseidon } = require("circomlibjs");
async function register(password, salt) {
const poseidon = await buildPoseidon();
const commitment = poseidon.F.toString(poseidon([password, salt]));
// Send ONLY the commitment to the server
return { commitment };
}
register(12345n, 67890n).then(console.log);
// { commitment: "1234...abc" }
The server stores { commitment }. If its database is ever leaked, attackers learn nothing usable — they’d still need to find a preimage of Poseidon, which is computationally infeasible.
Step 4: Login — Generating the Proof
At login time, the user proves knowledge of the preimage:
// login.js
const snarkjs = require("snarkjs");
const fs = require("fs");
async function login(password, salt) {
const input = {
password: password.toString(),
salt: salt.toString(),
commitment: JSON.parse(fs.readFileSync("commitment.json")).commitment
};
const { proof, publicSignals } = await snarkjs.groth16.fullProve(
input,
"auth_js/auth.wasm",
"auth_final.zkey"
);
// Send { proof, publicSignals } to the server
return { proof, publicSignals };
}
Step 5: Server-Side Verification
The server receives a proof and verifies it against the public commitment:
// server.js
const snarkjs = require("snarkjs");
const vKey = require("./verification_key.json");
async function verify({ proof, publicSignals }) {
return await snarkjs.groth16.verify(vKey, publicSignals, proof);
}
If publicSignals[0] matches the commitment registered for that user, authentication succeeds. The whole check takes a few milliseconds.
The Full Flow
REGISTRATION:
user: (password=12345, salt=67890)
│
├─► Poseidon(12345, 67890) = commitment
│
└─► server stores { commitment }
LOGIN:
user: (password=12345, salt=67890)
│
├─► generates ZK proof:
│ "∃ password, salt: H(password, salt) = commitment"
│
└─► sends { proof, publicSignals=[commitment] } ──► server
│
└─► verifies proof
The verifier learns only one bit of information: “the prover knows a valid preimage.” Nothing else.
Running the PoC
node register.js > commitment.json # generate and save the commitment node login.js # generate the proof
Expected output:
✔ Proof generated in ~1s ✔ Verification: true
Important Caveats
This PoC is intentionally minimal. A real-world system needs to address several issues:
- Replay attacks. The proof above is reusable. Add a server-issued
nonceas a public input in the circuit, or a timestamp with a freshness window. - Static salt. Each user should have a randomly generated salt. Here, it’s hardcoded for clarity.
- Trusted setup. Groth16 requires a setup ceremony. For trustless deployments, prefer transparent schemes like STARKs or PLONK.
- Low-entropy passwords. If passwords come from a small dictionary, an attacker with the commitment can brute-force them by recomputing the hash. Use high-entropy secrets (private keys, long passphrases) instead.
- Curve choice. This example uses BN254 (~128-bit security). Fine for a PoC, but verify your security assumptions for production.
Going Further
If you swap the circuit for one that proves:
“I know
sksuch thatPoseidon(sk) == pubkey“
…you get public-key ZK authentication — the foundation of passwordless login, blockchain identity, and Self-Sovereign Identity (SSI). Projects like Semaphore build directly on this pattern to enable anonymous group membership and private credential verification.
ZKPs turn authentication from “send me your secret so I can check it” into “prove you know it, and I’ll trust the math.” That shift is subtle but profound — and it’s already reshaping how we think about privacy in identity systems.
Conclusion
We built a full ZK-SNARK authentication flow in under a hundred lines of code:
- A Circom circuit encoding the statement.
- A registration step producing a public commitment.
- A login step producing a succinct, verifiable proof.
- A verification step requiring only the commitment and public parameters.
The password never leaves the user’s device — not even as a hash — and the server holds nothing worth stealing. For anyone building the next generation of identity systems, this is a pattern worth understanding deeply.
Next up: adding nonces for anti-replay, using PLONK for transparent setups, or porting to Rust with arkworks. Happy proving.
Prompt for AI Codex: Build a ZK-SNARK Authentication Demo
Below is a detailed, self-contained prompt you can feed to an AI coding assistant (Codex, Claude, GPT, Cursor, etc.) to generate a complete working application based on the ZK-SNARK authentication concept discussed above.
The Prompt
You are a senior full-stack engineer with deep expertise in zero-knowledge cryptography, Circom, snarkjs, and Node.js. Build a complete, working, minimal application that demonstrates passwordless authentication using ZK-SNARKs with the Groth16 proving scheme. ## Objective Create a "ZK-Auth Demo" — a small web application where a user registers with a secret (password + auto-generated salt) and later authenticates by producing a zero-knowledge proof that they know a preimage of a public commitment. The server must never receive the password, the salt, or any hash of them transmitted over the wire — only a proof and public signals. ## Tech Stack (mandatory) - Node.js 20+ - Express for the HTTP server - Circom 2.0 for the circuit - snarkjs for proving and verification - circomlib / circomlibjs for Poseidon hash - Vanilla JS + HTML frontend (no frameworks needed) - File-based storage (JSON) for users and commitments — no database ## Project Structure Generate the following layout:
zk-auth-demo/
├── circuits/
│ └── auth.circom
├── scripts/
│ ├── build.sh # compile circuit, download ptau, run groth16 setup
│ └── setup-keys.js # optional Node wrapper for the setup
├── src/
│ ├── server.js # Express server
│ ├── auth.js # registration + verification logic
│ ├── prover.js # client-side proof generation (imported by browser via bundler or served)
│ └── storage.js # simple JSON file persistence
├── public/
│ ├── index.html # single-page UI
│ ├── client.js # frontend logic: register, login, show proof
│ └── styles.css
├── build/ # generated: .wasm, .zkey, verification_key.json, .r1cs
├── data/
│ └── users.json # { username: { commitment } }
├── package.json
├── README.md
└── .gitignore
## Circuit Specification
`circuits/auth.circom`:
- Template `Auth` with:
- Private inputs: `password`, `salt`
- Public input: `commitment`
- Constraint: `Poseidon([password, salt]) === commitment`
- Use `circomlib/circuits/poseidon.circom`.
- Include a second template `AuthWithNonce` that additionally takes a public `nonce`
input and constrains `Poseidon([password, salt, nonce]) === commitmentWithNonce`,
to demonstrate anti-replay. Leave it commented out by default but fully written.
- `component main = Auth();`
## Backend Endpoints
Implement these REST endpoints:
1. `POST /api/register`
- Body: `{ username, commitment }`
- Validates username uniqueness, stores commitment in `data/users.json`
- Returns `{ ok: true }` or an error.
2. `POST /api/challenge`
- Body: `{ username }`
- Generates a random 32-byte nonce, stores it in memory with a 2-minute TTL,
returns `{ nonce }`.
- (This prepares for the anti-replay extension.)
3. `POST /api/login`
- Body: `{ username, proof, publicSignals }`
- Loads the stored commitment for the user
- Verifies that `publicSignals[0] === stored commitment`
- Runs `snarkjs.groth16.verify(vKey, publicSignals, proof)`
- Returns `{ authenticated: true }` or `{ authenticated: false, reason }`
4. `GET /api/verification-key`
- Returns the verification key JSON (useful for the frontend or third parties).
## Frontend Behavior
Single page with two sections:
- **Register**: input for username + password. On submit:
- Generate a random salt client-side (`crypto.getRandomValues`).
- Compute Poseidon commitment in the browser (bundle circomlibjs or load it via esm.sh / skypack).
- POST commitment to `/api/register`.
- Display the commitment and warn the user to save the password (nothing else is stored server-side).
- **Login**: input for username + password. On submit:
- Fetch the user's commitment? No — the client knows its own password and salt,
but for the demo, the client should re-prompt salt. Alternatively, cache the salt
in `localStorage` under `zk-auth-demo:<username>`.
- Generate the proof in-browser using `snarkjs.groth16.fullProve` with the
downloaded `auth.wasm` and `auth_final.zkey`.
- POST `{ username, proof, publicSignals }` to `/api/login`.
- Show the result and the raw proof JSON in a collapsible `<details>` block.
- **Log**: a scrolling panel that shows each step (commitment computed, proof generated,
verification result) with timestamps.
## Build Script
`scripts/build.sh` should:
1. Check for `circom`, `snarkjs`, `wget` and exit with a helpful message if missing.
2. Compile `circuits/auth.circom` into `build/` with `--r1cs --wasm --sym`.
3. Download the Powers of Tau file `powersOfTau28_hez_final_10.ptau` into `build/`
if not present.
4. Run `snarkjs groth16 setup` → `build/auth_0000.zkey`.
5. Run a single contribution → `build/auth_final.zkey`.
6. Export `build/verification_key.json`.
7. Export a Solidity verifier (optional, comment it) for reference.
8. Copy `build/auth_js/auth.wasm` and `build/auth_final.zkey` into `public/` so the
browser can fetch them.
Make the script idempotent and verbose (`set -euo pipefail`).
## Package.json
```json
{
"name": "zk-auth-demo",
"version": "0.1.0",
"type": "module",
"scripts": {
"build": "bash scripts/build.sh",
"start": "node src/server.js",
"dev": "node --watch src/server.js"
},
"dependencies": {
"express": "^4.19.0",
"snarkjs": "^0.7.4",
"circomlib": "^2.0.5",
"circomlibjs": "^0.1.7"
}
}
Constraints and Quality Bar
- All code must run locally with just
npm install,npm run build,npm start. - No external services, no databases, no Docker required.
- Include a thorough
README.mdcovering: prerequisites, install, build, run,
a “how it works” section, a security caveats section (replay, low-entropy passwords,
trusted setup), and a “next steps” section (nonce binding, PLONK, group membership). - Add inline comments in every file explaining why, not just what.
- Handle errors gracefully: missing keys, corrupted proofs, wrong commitment → return
clear 4xx responses with human-readable messages. - Log to stdout with a simple timestamped format.
- Never log passwords or salts.
Deliverables
Generate every file listed in the project structure, fully working, in a single response.
If a file is too long, still output it in full. Do not use placeholders like “// TODO” or
“// implementation here” — every function must be complete and runnable.
--- ## How to use this prompt 1. **Paste it as-is** into your AI coding tool of choice (Codex, Claude, Cursor, Aider, Continue, etc.). 2. If the model truncates output, ask it to **continue from the last file** — don't let it summarize. 3. After generation, run: ```bash npm install npm run build npm start
- Open
http://localhost:3000and try registering + logging in.