The Core Idea in 30 Seconds

A zero-knowledge proof lets you prove a statement is true without revealing the underlying data. For authentication:

Traditional auth:
 Client sends password → Server checks hash → Server knows the password

ZK auth:
 Client generates proof("I know a password that hashes to X") →
 Server verifies proof → Server learns NOTHING except "yes, they know it"

The server never sees the password. It never sees any intermediate values. It only learns a single bit of information: the proof is valid or it isn't.

This isn't theoretical. The math has been solid since the 1980s. What's new is that we finally have efficient enough proving systems to make this practical in a web browser.

How zk-SNARKs Work (Without the Academic Papers)

SNARK stands for "Succinct Non-interactive ARgument of Knowledge." Let me break down what each word means practically:

  • Succinct: The proof is small (192-512 bytes) regardless of how complex the statement is. Verifying the proof is fast (milliseconds).
  • Non-interactive: The prover sends the proof to the verifier in a single message. No back-and-forth.
  • ARgument of Knowledge: The prover actually knows the secret data, they can't fake the proof without it (under computational hardness assumptions).

The process has three phases:

1. Circuit Definition

You express your authentication logic as an arithmetic circuit, a directed acyclic graph of addition and multiplication gates over a finite field. This is where Circom comes in.

// credential_auth.circom
pragma circom 2.1.0;

include "circomlib/circuits/poseidon.circom";
include "circomlib/circuits/comparators.circom";

template CredentialAuth() {
 // Private inputs (known only to the prover)
 signal input secret; // user's secret credential
 signal input salt; // random salt

 // Public inputs (known to both prover and verifier)
 signal input credentialHash; // Poseidon(secret, salt), registered on server
 signal input nullifier; // prevents replay
 signal input domainSeparator; // service-specific constant

 // Verify the credential
 component hasher = Poseidon(2);
 hasher.inputs[0] <== secret;
 hasher.inputs[1] <== salt;
 hasher.out === credentialHash;

 // Compute nullifier (prevents proof replay)
 component nullifierHasher = Poseidon(2);
 nullifierHasher.inputs[0] <== secret;
 nullifierHasher.inputs[1] <== domainSeparator;
 nullifierHasher.out === nullifier;
}

component main {public [credentialHash, nullifier, domainSeparator]} = CredentialAuth();

This circuit says: "I know a secret and salt that hash to credentialHash, and here's a nullifier derived from my secret and your domain separator." The server stores credentialHash during registration and checks the nullifier for replay prevention.

2. Trusted Setup

For Groth16, you need a trusted setup ceremony that generates the proving key and verification key for this specific circuit:

# Compile the circuit
circom credential_auth.circom --r1cs --wasm --sym -o build/

# Start the trusted setup (Powers of Tau ceremony)
snarkjs powersoftau new bn128 14 pot14_0000.ptau
snarkjs powersoftau contribute pot14_0000.ptau pot14_0001.ptau \
 --name="First contribution" -e="random entropy here"
snarkjs powersoftau prepare phase2 pot14_0001.ptau pot14_final.ptau

# Generate circuit-specific keys
snarkjs groth16 setup build/credential_auth.r1cs pot14_final.ptau \
 credential_auth_0000.zkey
snarkjs zkey contribute credential_auth_0000.zkey credential_auth_final.zkey \
 --name="Circuit contribution" -e="more random entropy"

# Export verification key (this goes to the server)
snarkjs zkey export verificationkey credential_auth_final.zkey verification_key.json

The trusted setup is Groth16's biggest practical limitation. If you modify the circuit (even adding one constraint), you need a new ceremony. This is why Plonk's universal setup is attractive for rapidly iterating auth systems.

3. Proof Generation and Verification

On the client side (in the browser via WASM):

import * as snarkjs from "snarkjs";

async function generateAuthProof(secret, salt, credentialHash, domainSeparator) {
 // Compute the nullifier client-side
 // (we'd use a JS Poseidon implementation here)
 const nullifier = poseidonHash([secret, domainSeparator]);

 const input = {
 secret: secret,
 salt: salt,
 credentialHash: credentialHash,
 nullifier: nullifier,
 domainSeparator: domainSeparator,
 };

 // Generate the proof (this takes ~1-2 seconds in browser)
 const { proof, publicSignals } = await snarkjs.groth16.fullProve(
 input,
 "/circuits/credential_auth.wasm", // compiled circuit
 "/circuits/credential_auth_final.zkey" // proving key
 );

 return { proof, publicSignals };
}

// Send to server
const { proof, publicSignals } = await generateAuthProof(
 mySecret, mySalt, registeredHash, "app.example.com"
);

await fetch("/api/auth/verify", {
 method: "POST",
 body: JSON.stringify({ proof, publicSignals }),
});

On the server side:

import * as snarkjs from "snarkjs";
import vkey from "./verification_key.json";

async function verifyAuthProof(proof, publicSignals) {
 // publicSignals[0] = credentialHash
 // publicSignals[1] = nullifier
 // publicSignals[2] = domainSeparator

 // Verify the ZK proof
 const isValid = await snarkjs.groth16.verify(vkey, publicSignals, proof);
 if (!isValid) return { authenticated: false };

 // Check the credential hash exists in our database
 const credential = await db.findOne({
 credentialHash: publicSignals[0]
 });
 if (!credential) return { authenticated: false };

 // Check nullifier hasn't been used (prevent replay)
 const nullifierUsed = await db.nullifiers.findOne({
 value: publicSignals[1]
 });
 if (nullifierUsed) return { authenticated: false, error: "proof_replayed" };

 // Store the nullifier
 await db.nullifiers.insertOne({
 value: publicSignals[1],
 timestamp: Date.now(),
 credentialId: credential.id,
 });

 return { authenticated: true, userId: credential.userId };
}

Practical Example: Age Verification Without Revealing Age

This is a compelling use case. Here is a circuit that proves "I am over 18" without revealing the actual birth date:

pragma circom 2.1.0;

include "circomlib/circuits/poseidon.circom";
include "circomlib/circuits/comparators.circom";
include "circomlib/circuits/bitify.circom";

template AgeVerification() {
 signal input birthDate; // private: Unix timestamp of birth
 signal input identitySecret; // private: user's identity secret
 signal input salt; // private: random salt

 signal input currentDate; // public: current Unix timestamp
 signal input minAge; // public: minimum age in seconds
 signal input identityHash; // public: Poseidon(identitySecret, salt)

 // Verify identity ownership
 component idHasher = Poseidon(2);
 idHasher.inputs[0] <== identitySecret;
 idHasher.inputs[1] <== salt;
 idHasher.out === identityHash;

 // Calculate age = currentDate - birthDate
 signal age;
 age <== currentDate - birthDate;

 // Prove age >= minAge using GreaterEqThan
 // (operates on 64-bit numbers)
 component ageCheck = GreaterEqThan(64);
 ageCheck.in[0] <== age;
 ageCheck.in[1] <== minAge;
 ageCheck.out === 1;

 // Ensure birthDate is positive and reasonable
 component birthPositive = GreaterThan(64);
 birthPositive.in[0] <== birthDate;
 birthPositive.in[1] <== 0;
 birthPositive.out === 1;
}

component main {public [currentDate, minAge, identityHash]} = AgeVerification();

The server sets currentDate and minAge (18 years in seconds = 567,648,000). The user proves they satisfy the age requirement without ever revealing when they were born. The server can't even compute what year they were born in, all it learns is "this person controls the identity and is old enough."

Performance Considerations

Here are real benchmarks from production-like conditions:

Circuit: CredentialAuth (Poseidon hash + nullifier)
Constraints: ~640

Groth16:
 Trusted setup: ~2 seconds (one-time)
 Proof generation (browser WASM): 1.1 seconds
 Proof generation (Node.js native): 0.3 seconds
 Proof size: 192 bytes
 Verification time: 3ms

Plonk:
 Universal setup: ~10 seconds (one-time, reusable)
 Proof generation (browser WASM): 2.8 seconds
 Proof generation (Node.js native): 0.8 seconds
 Proof size: 512 bytes
 Verification time: 5ms

Circuit: AgeVerification (Poseidon + comparison)
Constraints: ~1,100

Groth16:
 Proof generation (browser WASM): 1.8 seconds
 Proof size: 192 bytes
 Verification time: 3ms

The key number is proof generation time in the browser. At 1-2 seconds, it's usable but not great for UX. You'd show a "Generating secure proof..." spinner. For native mobile apps with GPU-accelerated proof generation, this drops to under 500ms.

Beyond Auth: What Else Can You Do?

Once you have the ZK infrastructure in place, the same patterns apply to:

Credential ownership proofs: Prove you have a valid credential from issuer X without revealing the credential contents. Think: proving you're a verified employee of Company Y without revealing your employee ID, department, or salary.

Set membership: Prove you're on a whitelist without revealing which member you are. Useful for access control where you want unlinkability, the server knows "an authorized user accessed this resource" but can't tell which one.

template MerkleProof(levels) {
 signal input leaf;
 signal input pathElements[levels];
 signal input pathIndices[levels];
 signal input root; // public: Merkle root of the whitelist

 component hashers[levels];
 signal intermediate[levels + 1];
 intermediate[0] <== leaf;

 for (var i = 0; i < levels; i++) {
 hashers[i] = Poseidon(2);
 // If pathIndex is 0, our node is on the left
 // If pathIndex is 1, our node is on the right
 hashers[i].inputs[0] <== intermediate[i] + (pathElements[i] - intermediate[i]) * pathIndices[i];
 hashers[i].inputs[1] <== pathElements[i] + (intermediate[i] - pathElements[i]) * pathIndices[i];
 intermediate[i + 1] <== hashers[i].out;
 }

 root === intermediate[levels];
}

Range proofs: Prove a value is within a range without revealing the value. Credit score is above 700. Account balance is above $1000. These are direct applications of the comparison circuit pattern.

The Honest Assessment

ZK auth has real limitations that the hype machine glosses over:

  1. Key management is the user's problem. If they lose their secret, there's no "forgot password" flow. You need a recovery mechanism, which typically involves either a trusted third party (defeating some privacy benefits) or social recovery (complex UX).

  2. Proof generation on low-end devices is slow. A $100 Android phone might take 5-8 seconds to generate a Groth16 proof. That's a UX killer for mainstream apps.

  3. Circuit development is hard. Circom is not a general-purpose language. Debugging constraint systems is fundamentally different from debugging imperative code. Off-by-one errors in constraint logic can create soundness bugs that let attackers forge proofs.

  4. The setup ceremony question. For Groth16, if the trusted setup is compromised, an attacker can forge proofs. Multi-party computation ceremonies mitigate this, but they add operational complexity.

Despite all of this, ZK authentication is likely to become mainstream within five years. Not as a replacement for OAuth everywhere, but as a specialized tool for privacy-sensitive flows: age verification, credential checking, anonymous voting, and access control where unlinkability matters.

The cryptography is sound. The tooling is improving rapidly. The missing piece is UX, and that's a solvable engineering problem. For anyone building systems that handle sensitive identity data, investing in understanding ZK fundamentals now is worthwhile. By the time ZK auth libraries are as easy to use as bcrypt, having that foundational knowledge will be a significant advantage.