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 password and a salt such that Poseidon(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 (passwordsalt) 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:

  1. Replay attacks. The proof above is reusable. Add a server-issued nonce as a public input in the circuit, or a timestamp with a freshness window.
  2. Static salt. Each user should have a randomly generated salt. Here, it’s hardcoded for clarity.
  3. Trusted setup. Groth16 requires a setup ceremony. For trustless deployments, prefer transparent schemes like STARKs or PLONK.
  4. 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.
  5. 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 sk such that Poseidon(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 installnpm run buildnpm start.
  • No external services, no databases, no Docker required.
  • Include a thorough README.md covering: 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
  1. Open http://localhost:3000 and try registering + logging in.