<!-- Canonical: https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication
Language: en
Content-SHA256: 1df0c54f147158bbb5b55a09c2282447e0e67c320278d3391eb19743e103de54
-->

Cryptography

# Zero-Knowledge Proofs for Authentication: Privacy-Preserving Identity Verification

2025-11-049 min read

Lucio Durán
Engineering Manager & AI Solutions Architect

Also available in: [Español](<https://lucioduran.com/es/blog/zero-knowledge-proofs-web3-authentication>)

## 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](<https://docs.circom.io/>) comes in.

```circom
// 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:

```bash
# 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):

```javascript
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:

```javascript
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:

```circom
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.

```circom
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.

## Frequently asked questions

### Do I need a blockchain to use zero-knowledge proofs for authentication?

Absolutely not. ZK proofs are a mathematical primitive, they work just fine with a traditional server-side verifier. The blockchain hype around ZK has obscured the fact that ZK auth is useful for any system where you want to prove identity without revealing credentials.

### How long does it take to generate a ZK proof on a user's device?

For a simple credential proof (hash preimage with Poseidon), roughly 500ms-2s on a modern smartphone using WASM-compiled provers. Groth16 is faster to prove but needs per-circuit parameters downloaded upfront. Plonk is slower but more flexible.

### Is ZK authentication production-ready in 2026?

For specific use cases, yes. Age verification, credential ownership, and membership proofs are production-ready. General-purpose ZK auth replacing OAuth is still experimental due to UX challenges (proof generation time, key management).

## Tips

1. **Groth16 vs Plonk trade-off:** Groth16 produces the smallest proofs (~192 bytes) and fastest verification (~3ms), but requires a **trusted setup per circuit**. Plonk uses a universal trusted setup that works for any circuit up to a fixed size, with slightly larger proofs (~512 bytes) and ~5ms verification. For production auth, use Plonk unless proof size is critical (e.g., on-chain verification where gas matters).
2. **Circom signal assignment vs constraint:** In Circom, ` <-- ` assigns a value to a signal (witness computation) while ` === ` creates a constraint (proof verification). A common bug is using ` <-- ` without a corresponding ` === `, which means the prover can assign any value without the verifier catching it. Always use ` <== ` (assign + constrain) unless you have a specific reason to separate them.
3. **Poseidon over Pedersen for ZK hashing:** Use the ` Poseidon ` hash from circomlib instead of ` Pedersen ` for credential hashing. Poseidon requires ~300 constraints vs Pedersen's ~1,500 for a 256-bit hash. Fewer constraints = faster proof generation. In auth flows where the user generates proofs client-side, this directly impacts UX latency.
4. **Nullifier pattern for replay prevention:** Every ZK auth proof should include a ` nullifier = Poseidon(secret, domain_separator) `. The verifier stores used nullifiers. This prevents proof replay without revealing which credential was used. Use a unique ` domain_separator ` per service to prevent cross-service nullifier correlation.

- zero-knowledge-proofs
- zk-snarks
- groth16
- plonk
- circom
- authentication
- cryptography

[Back to Blog](<https://lucioduran.com/blog>)
