{"schemaVersion":1,"id":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication","url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication","title":"Zero-Knowledge Proofs for Authentication: Privacy-Preserving Identity Verification","description":"How to implement ZK-based authentication using zk-SNARKs, Groth16, and Plonk. Circom circuit design, practical examples of proving identity without revealing credentials, and why ZK auth is useful far beyond Web3.","language":"en","datePublished":"2025-11-04","dateModified":null,"contentFormat":"text/markdown","content":"Cryptography\n\n# Zero-Knowledge Proofs for Authentication: Privacy-Preserving Identity Verification\n\n2025-11-049 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/zero-knowledge-proofs-web3-authentication>)\n\n## The Core Idea in 30 Seconds\n\nA zero-knowledge proof lets you prove a statement is true without revealing the underlying data. For authentication:\n\n```\nTraditional auth:\n Client sends password → Server checks hash → Server knows the password\n\nZK auth:\n Client generates proof(\"I know a password that hashes to X\") →\n Server verifies proof → Server learns NOTHING except \"yes, they know it\"\n```\n\nThe 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.\n\nThis 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.\n\n## How zk-SNARKs Work (Without the Academic Papers)\n\nSNARK stands for \"Succinct Non-interactive ARgument of Knowledge.\" Let me break down what each word means practically:\n\n- **Succinct**: The proof is small (192-512 bytes) regardless of how complex the statement is. Verifying the proof is fast (milliseconds).\n- **Non-interactive**: The prover sends the proof to the verifier in a single message. No back-and-forth.\n- **ARgument of Knowledge**: The prover actually knows the secret data, they can't fake the proof without it (under computational hardness assumptions).\n\nThe process has three phases:\n\n### 1. Circuit Definition\n\nYou 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.\n\n```circom\n// credential_auth.circom\npragma circom 2.1.0;\n\ninclude \"circomlib/circuits/poseidon.circom\";\ninclude \"circomlib/circuits/comparators.circom\";\n\ntemplate CredentialAuth() {\n // Private inputs (known only to the prover)\n signal input secret; // user's secret credential\n signal input salt; // random salt\n\n // Public inputs (known to both prover and verifier)\n signal input credentialHash; // Poseidon(secret, salt), registered on server\n signal input nullifier; // prevents replay\n signal input domainSeparator; // service-specific constant\n\n // Verify the credential\n component hasher = Poseidon(2);\n hasher.inputs[0] <== secret;\n hasher.inputs[1] <== salt;\n hasher.out === credentialHash;\n\n // Compute nullifier (prevents proof replay)\n component nullifierHasher = Poseidon(2);\n nullifierHasher.inputs[0] <== secret;\n nullifierHasher.inputs[1] <== domainSeparator;\n nullifierHasher.out === nullifier;\n}\n\ncomponent main {public [credentialHash, nullifier, domainSeparator]} = CredentialAuth();\n```\n\nThis 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.\n\n### 2. Trusted Setup\n\nFor Groth16, you need a trusted setup ceremony that generates the proving key and verification key for this specific circuit:\n\n```bash\n# Compile the circuit\ncircom credential_auth.circom --r1cs --wasm --sym -o build/\n\n# Start the trusted setup (Powers of Tau ceremony)\nsnarkjs powersoftau new bn128 14 pot14_0000.ptau\nsnarkjs powersoftau contribute pot14_0000.ptau pot14_0001.ptau \\\n --name=\"First contribution\" -e=\"random entropy here\"\nsnarkjs powersoftau prepare phase2 pot14_0001.ptau pot14_final.ptau\n\n# Generate circuit-specific keys\nsnarkjs groth16 setup build/credential_auth.r1cs pot14_final.ptau \\\n credential_auth_0000.zkey\nsnarkjs zkey contribute credential_auth_0000.zkey credential_auth_final.zkey \\\n --name=\"Circuit contribution\" -e=\"more random entropy\"\n\n# Export verification key (this goes to the server)\nsnarkjs zkey export verificationkey credential_auth_final.zkey verification_key.json\n```\n\nThe 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.\n\n### 3. Proof Generation and Verification\n\nOn the client side (in the browser via WASM):\n\n```javascript\nimport * as snarkjs from \"snarkjs\";\n\nasync function generateAuthProof(secret, salt, credentialHash, domainSeparator) {\n // Compute the nullifier client-side\n // (we'd use a JS Poseidon implementation here)\n const nullifier = poseidonHash([secret, domainSeparator]);\n\n const input = {\n secret: secret,\n salt: salt,\n credentialHash: credentialHash,\n nullifier: nullifier,\n domainSeparator: domainSeparator,\n };\n\n // Generate the proof (this takes ~1-2 seconds in browser)\n const { proof, publicSignals } = await snarkjs.groth16.fullProve(\n input,\n \"/circuits/credential_auth.wasm\", // compiled circuit\n \"/circuits/credential_auth_final.zkey\" // proving key\n );\n\n return { proof, publicSignals };\n}\n\n// Send to server\nconst { proof, publicSignals } = await generateAuthProof(\n mySecret, mySalt, registeredHash, \"app.example.com\"\n);\n\nawait fetch(\"/api/auth/verify\", {\n method: \"POST\",\n body: JSON.stringify({ proof, publicSignals }),\n});\n```\n\nOn the server side:\n\n```javascript\nimport * as snarkjs from \"snarkjs\";\nimport vkey from \"./verification_key.json\";\n\nasync function verifyAuthProof(proof, publicSignals) {\n // publicSignals[0] = credentialHash\n // publicSignals[1] = nullifier\n // publicSignals[2] = domainSeparator\n\n // Verify the ZK proof\n const isValid = await snarkjs.groth16.verify(vkey, publicSignals, proof);\n if (!isValid) return { authenticated: false };\n\n // Check the credential hash exists in our database\n const credential = await db.findOne({\n credentialHash: publicSignals[0]\n });\n if (!credential) return { authenticated: false };\n\n // Check nullifier hasn't been used (prevent replay)\n const nullifierUsed = await db.nullifiers.findOne({\n value: publicSignals[1]\n });\n if (nullifierUsed) return { authenticated: false, error: \"proof_replayed\" };\n\n // Store the nullifier\n await db.nullifiers.insertOne({\n value: publicSignals[1],\n timestamp: Date.now(),\n credentialId: credential.id,\n });\n\n return { authenticated: true, userId: credential.userId };\n}\n```\n\n## Practical Example: Age Verification Without Revealing Age\n\nThis is a compelling use case. Here is a circuit that proves \"I am over 18\" without revealing the actual birth date:\n\n```circom\npragma circom 2.1.0;\n\ninclude \"circomlib/circuits/poseidon.circom\";\ninclude \"circomlib/circuits/comparators.circom\";\ninclude \"circomlib/circuits/bitify.circom\";\n\ntemplate AgeVerification() {\n signal input birthDate; // private: Unix timestamp of birth\n signal input identitySecret; // private: user's identity secret\n signal input salt; // private: random salt\n\n signal input currentDate; // public: current Unix timestamp\n signal input minAge; // public: minimum age in seconds\n signal input identityHash; // public: Poseidon(identitySecret, salt)\n\n // Verify identity ownership\n component idHasher = Poseidon(2);\n idHasher.inputs[0] <== identitySecret;\n idHasher.inputs[1] <== salt;\n idHasher.out === identityHash;\n\n // Calculate age = currentDate - birthDate\n signal age;\n age <== currentDate - birthDate;\n\n // Prove age >= minAge using GreaterEqThan\n // (operates on 64-bit numbers)\n component ageCheck = GreaterEqThan(64);\n ageCheck.in[0] <== age;\n ageCheck.in[1] <== minAge;\n ageCheck.out === 1;\n\n // Ensure birthDate is positive and reasonable\n component birthPositive = GreaterThan(64);\n birthPositive.in[0] <== birthDate;\n birthPositive.in[1] <== 0;\n birthPositive.out === 1;\n}\n\ncomponent main {public [currentDate, minAge, identityHash]} = AgeVerification();\n```\n\nThe 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.\"\n\n## Performance Considerations\n\nHere are real benchmarks from production-like conditions:\n\n```\nCircuit: CredentialAuth (Poseidon hash + nullifier)\nConstraints: ~640\n\nGroth16:\n Trusted setup: ~2 seconds (one-time)\n Proof generation (browser WASM): 1.1 seconds\n Proof generation (Node.js native): 0.3 seconds\n Proof size: 192 bytes\n Verification time: 3ms\n\nPlonk:\n Universal setup: ~10 seconds (one-time, reusable)\n Proof generation (browser WASM): 2.8 seconds\n Proof generation (Node.js native): 0.8 seconds\n Proof size: 512 bytes\n Verification time: 5ms\n\nCircuit: AgeVerification (Poseidon + comparison)\nConstraints: ~1,100\n\nGroth16:\n Proof generation (browser WASM): 1.8 seconds\n Proof size: 192 bytes\n Verification time: 3ms\n```\n\nThe 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.\n\n## Beyond Auth: What Else Can You Do?\n\nOnce you have the ZK infrastructure in place, the same patterns apply to:\n\n**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.\n\n**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.\n\n```circom\ntemplate MerkleProof(levels) {\n signal input leaf;\n signal input pathElements[levels];\n signal input pathIndices[levels];\n signal input root; // public: Merkle root of the whitelist\n\n component hashers[levels];\n signal intermediate[levels + 1];\n intermediate[0] <== leaf;\n\n for (var i = 0; i < levels; i++) {\n hashers[i] = Poseidon(2);\n // If pathIndex is 0, our node is on the left\n // If pathIndex is 1, our node is on the right\n hashers[i].inputs[0] <== intermediate[i] + (pathElements[i] - intermediate[i]) * pathIndices[i];\n hashers[i].inputs[1] <== pathElements[i] + (intermediate[i] - pathElements[i]) * pathIndices[i];\n intermediate[i + 1] <== hashers[i].out;\n }\n\n root === intermediate[levels];\n}\n```\n\n**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.\n\n## The Honest Assessment\n\nZK auth has real limitations that the hype machine glosses over:\n\n1. **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).\n2. **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.\n3. **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.\n4. **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.\n\nDespite 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.\n\nThe 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.\n\n## Frequently asked questions\n\n### Do I need a blockchain to use zero-knowledge proofs for authentication?\n\nAbsolutely 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.\n\n### How long does it take to generate a ZK proof on a user's device?\n\nFor 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.\n\n### Is ZK authentication production-ready in 2026?\n\nFor 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).\n\n## Tips\n\n1. **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).\n2. **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.\n3. **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.\n4. **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.\n\n- zero-knowledge-proofs\n- zk-snarks\n- groth16\n- plonk\n- circom\n- authentication\n- cryptography\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"Zero-Knowledge Proofs for Authentication: Privacy-Preserving Identity Verification","url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#title"},{"level":2,"text":"The Core Idea in 30 Seconds","url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#the-core-idea-in-30-seconds"},{"level":2,"text":"How zk-SNARKs Work (Without the Academic Papers)","url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#how-zk-snarks-work-without-the-academic-papers"},{"level":3,"text":"1. Circuit Definition","url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#1-circuit-definition"},{"level":3,"text":"2. Trusted Setup","url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#2-trusted-setup"},{"level":3,"text":"3. Proof Generation and Verification","url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#3-proof-generation-and-verification"},{"level":2,"text":"Practical Example: Age Verification Without Revealing Age","url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#practical-example-age-verification-without-revealing-age"},{"level":2,"text":"Performance Considerations","url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#performance-considerations"},{"level":2,"text":"Beyond Auth: What Else Can You Do?","url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#beyond-auth-what-else-can-you-do"},{"level":2,"text":"The Honest Assessment","url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#the-honest-assessment"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#questions"},{"level":3,"text":"Do I need a blockchain to use zero-knowledge proofs for authentication?","url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#question-1"},{"level":3,"text":"How long does it take to generate a ZK proof on a user's device?","url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#question-2"},{"level":3,"text":"Is ZK authentication production-ready in 2026?","url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#article-tips"}],"sections":[{"heading":"Zero-Knowledge Proofs for Authentication: Privacy-Preserving Identity Verification","level":1,"url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication","content":"Cryptography"},{"heading":"Zero-Knowledge Proofs for Authentication: Privacy-Preserving Identity Verification","level":1,"url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#title","content":"2025-11-049 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/zero-knowledge-proofs-web3-authentication>)"},{"heading":"The Core Idea in 30 Seconds","level":2,"url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#the-core-idea-in-30-seconds","content":"A zero-knowledge proof lets you prove a statement is true without revealing the underlying data. For authentication:\n\n```\nTraditional auth:\n Client sends password → Server checks hash → Server knows the password\n\nZK auth:\n Client generates proof(\"I know a password that hashes to X\") →\n Server verifies proof → Server learns NOTHING except \"yes, they know it\"\n```\n\nThe 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.\n\nThis 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."},{"heading":"How zk-SNARKs Work (Without the Academic Papers)","level":2,"url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#how-zk-snarks-work-without-the-academic-papers","content":"SNARK stands for \"Succinct Non-interactive ARgument of Knowledge.\" Let me break down what each word means practically:\n\n- **Succinct**: The proof is small (192-512 bytes) regardless of how complex the statement is. Verifying the proof is fast (milliseconds).\n- **Non-interactive**: The prover sends the proof to the verifier in a single message. No back-and-forth.\n- **ARgument of Knowledge**: The prover actually knows the secret data, they can't fake the proof without it (under computational hardness assumptions).\n\nThe process has three phases:"},{"heading":"1. Circuit Definition","level":3,"url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#1-circuit-definition","content":"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.\n\n```circom\n// credential_auth.circom\npragma circom 2.1.0;\n\ninclude \"circomlib/circuits/poseidon.circom\";\ninclude \"circomlib/circuits/comparators.circom\";\n\ntemplate CredentialAuth() {\n // Private inputs (known only to the prover)\n signal input secret; // user's secret credential\n signal input salt; // random salt\n\n // Public inputs (known to both prover and verifier)\n signal input credentialHash; // Poseidon(secret, salt), registered on server\n signal input nullifier; // prevents replay\n signal input domainSeparator; // service-specific constant\n\n // Verify the credential\n component hasher = Poseidon(2);\n hasher.inputs[0] <== secret;\n hasher.inputs[1] <== salt;\n hasher.out === credentialHash;\n\n // Compute nullifier (prevents proof replay)\n component nullifierHasher = Poseidon(2);\n nullifierHasher.inputs[0] <== secret;\n nullifierHasher.inputs[1] <== domainSeparator;\n nullifierHasher.out === nullifier;\n}\n\ncomponent main {public [credentialHash, nullifier, domainSeparator]} = CredentialAuth();\n```\n\nThis 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."},{"heading":"2. Trusted Setup","level":3,"url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#2-trusted-setup","content":"For Groth16, you need a trusted setup ceremony that generates the proving key and verification key for this specific circuit:\n\n```bash\n# Compile the circuit\ncircom credential_auth.circom --r1cs --wasm --sym -o build/\n\n# Start the trusted setup (Powers of Tau ceremony)\nsnarkjs powersoftau new bn128 14 pot14_0000.ptau\nsnarkjs powersoftau contribute pot14_0000.ptau pot14_0001.ptau \\\n --name=\"First contribution\" -e=\"random entropy here\"\nsnarkjs powersoftau prepare phase2 pot14_0001.ptau pot14_final.ptau\n\n# Generate circuit-specific keys\nsnarkjs groth16 setup build/credential_auth.r1cs pot14_final.ptau \\\n credential_auth_0000.zkey\nsnarkjs zkey contribute credential_auth_0000.zkey credential_auth_final.zkey \\\n --name=\"Circuit contribution\" -e=\"more random entropy\"\n\n# Export verification key (this goes to the server)\nsnarkjs zkey export verificationkey credential_auth_final.zkey verification_key.json\n```\n\nThe 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."},{"heading":"3. Proof Generation and Verification","level":3,"url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#3-proof-generation-and-verification","content":"On the client side (in the browser via WASM):\n\n```javascript\nimport * as snarkjs from \"snarkjs\";\n\nasync function generateAuthProof(secret, salt, credentialHash, domainSeparator) {\n // Compute the nullifier client-side\n // (we'd use a JS Poseidon implementation here)\n const nullifier = poseidonHash([secret, domainSeparator]);\n\n const input = {\n secret: secret,\n salt: salt,\n credentialHash: credentialHash,\n nullifier: nullifier,\n domainSeparator: domainSeparator,\n };\n\n // Generate the proof (this takes ~1-2 seconds in browser)\n const { proof, publicSignals } = await snarkjs.groth16.fullProve(\n input,\n \"/circuits/credential_auth.wasm\", // compiled circuit\n \"/circuits/credential_auth_final.zkey\" // proving key\n );\n\n return { proof, publicSignals };\n}\n\n// Send to server\nconst { proof, publicSignals } = await generateAuthProof(\n mySecret, mySalt, registeredHash, \"app.example.com\"\n);\n\nawait fetch(\"/api/auth/verify\", {\n method: \"POST\",\n body: JSON.stringify({ proof, publicSignals }),\n});\n```\n\nOn the server side:\n\n```javascript\nimport * as snarkjs from \"snarkjs\";\nimport vkey from \"./verification_key.json\";\n\nasync function verifyAuthProof(proof, publicSignals) {\n // publicSignals[0] = credentialHash\n // publicSignals[1] = nullifier\n // publicSignals[2] = domainSeparator\n\n // Verify the ZK proof\n const isValid = await snarkjs.groth16.verify(vkey, publicSignals, proof);\n if (!isValid) return { authenticated: false };\n\n // Check the credential hash exists in our database\n const credential = await db.findOne({\n credentialHash: publicSignals[0]\n });\n if (!credential) return { authenticated: false };\n\n // Check nullifier hasn't been used (prevent replay)\n const nullifierUsed = await db.nullifiers.findOne({\n value: publicSignals[1]\n });\n if (nullifierUsed) return { authenticated: false, error: \"proof_replayed\" };\n\n // Store the nullifier\n await db.nullifiers.insertOne({\n value: publicSignals[1],\n timestamp: Date.now(),\n credentialId: credential.id,\n });\n\n return { authenticated: true, userId: credential.userId };\n}\n```"},{"heading":"Practical Example: Age Verification Without Revealing Age","level":2,"url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#practical-example-age-verification-without-revealing-age","content":"This is a compelling use case. Here is a circuit that proves \"I am over 18\" without revealing the actual birth date:\n\n```circom\npragma circom 2.1.0;\n\ninclude \"circomlib/circuits/poseidon.circom\";\ninclude \"circomlib/circuits/comparators.circom\";\ninclude \"circomlib/circuits/bitify.circom\";\n\ntemplate AgeVerification() {\n signal input birthDate; // private: Unix timestamp of birth\n signal input identitySecret; // private: user's identity secret\n signal input salt; // private: random salt\n\n signal input currentDate; // public: current Unix timestamp\n signal input minAge; // public: minimum age in seconds\n signal input identityHash; // public: Poseidon(identitySecret, salt)\n\n // Verify identity ownership\n component idHasher = Poseidon(2);\n idHasher.inputs[0] <== identitySecret;\n idHasher.inputs[1] <== salt;\n idHasher.out === identityHash;\n\n // Calculate age = currentDate - birthDate\n signal age;\n age <== currentDate - birthDate;\n\n // Prove age >= minAge using GreaterEqThan\n // (operates on 64-bit numbers)\n component ageCheck = GreaterEqThan(64);\n ageCheck.in[0] <== age;\n ageCheck.in[1] <== minAge;\n ageCheck.out === 1;\n\n // Ensure birthDate is positive and reasonable\n component birthPositive = GreaterThan(64);\n birthPositive.in[0] <== birthDate;\n birthPositive.in[1] <== 0;\n birthPositive.out === 1;\n}\n\ncomponent main {public [currentDate, minAge, identityHash]} = AgeVerification();\n```\n\nThe 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.\""},{"heading":"Performance Considerations","level":2,"url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#performance-considerations","content":"Here are real benchmarks from production-like conditions:\n\n```\nCircuit: CredentialAuth (Poseidon hash + nullifier)\nConstraints: ~640\n\nGroth16:\n Trusted setup: ~2 seconds (one-time)\n Proof generation (browser WASM): 1.1 seconds\n Proof generation (Node.js native): 0.3 seconds\n Proof size: 192 bytes\n Verification time: 3ms\n\nPlonk:\n Universal setup: ~10 seconds (one-time, reusable)\n Proof generation (browser WASM): 2.8 seconds\n Proof generation (Node.js native): 0.8 seconds\n Proof size: 512 bytes\n Verification time: 5ms\n\nCircuit: AgeVerification (Poseidon + comparison)\nConstraints: ~1,100\n\nGroth16:\n Proof generation (browser WASM): 1.8 seconds\n Proof size: 192 bytes\n Verification time: 3ms\n```\n\nThe 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."},{"heading":"Beyond Auth: What Else Can You Do?","level":2,"url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#beyond-auth-what-else-can-you-do","content":"Once you have the ZK infrastructure in place, the same patterns apply to:\n\n**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.\n\n**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.\n\n```circom\ntemplate MerkleProof(levels) {\n signal input leaf;\n signal input pathElements[levels];\n signal input pathIndices[levels];\n signal input root; // public: Merkle root of the whitelist\n\n component hashers[levels];\n signal intermediate[levels + 1];\n intermediate[0] <== leaf;\n\n for (var i = 0; i < levels; i++) {\n hashers[i] = Poseidon(2);\n // If pathIndex is 0, our node is on the left\n // If pathIndex is 1, our node is on the right\n hashers[i].inputs[0] <== intermediate[i] + (pathElements[i] - intermediate[i]) * pathIndices[i];\n hashers[i].inputs[1] <== pathElements[i] + (intermediate[i] - pathElements[i]) * pathIndices[i];\n intermediate[i + 1] <== hashers[i].out;\n }\n\n root === intermediate[levels];\n}\n```\n\n**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."},{"heading":"The Honest Assessment","level":2,"url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#the-honest-assessment","content":"ZK auth has real limitations that the hype machine glosses over:\n\n1. **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).\n2. **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.\n3. **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.\n4. **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.\n\nDespite 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.\n\nThe 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."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#questions","content":""},{"heading":"Do I need a blockchain to use zero-knowledge proofs for authentication?","level":3,"url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#question-1","content":"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."},{"heading":"How long does it take to generate a ZK proof on a user's device?","level":3,"url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#question-2","content":"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."},{"heading":"Is ZK authentication production-ready in 2026?","level":3,"url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#question-3","content":"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)."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#article-tips","content":"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).\n2. **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.\n3. **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.\n4. **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.\n\n- zero-knowledge-proofs\n- zk-snarks\n- groth16\n- plonk\n- circom\n- authentication\n- cryptography\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Español","url":"https://lucioduran.com/es/blog/zero-knowledge-proofs-web3-authentication"},{"text":"Circom","url":"https://docs.circom.io/"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication"},"headline":"Zero-Knowledge Proofs for Authentication: Privacy-Preserving Identity Verification","description":"How to implement ZK-based authentication using zk-SNARKs, Groth16, and Plonk. Circom circuit design, practical examples of proving identity without revealing credentials, and why ZK auth is useful far beyond Web3.","datePublished":"2025-11-04","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://lucioduran.com/favicons/og-default.png","url":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Cryptography","keywords":"zero-knowledge-proofs, zk-snarks, groth16, plonk, circom, authentication, cryptography","wordCount":1571,"timeRequired":"PT9M","workTranslation":[{"@type":"BlogPosting","inLanguage":"es","url":"https://lucioduran.com/es/blog/zero-knowledge-proofs-web3-authentication"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"Zero-Knowledge Proofs for Authentication: Privacy-Preserving Identity Verification","item":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Do I need a blockchain to use zero-knowledge proofs for authentication?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"How long does it take to generate a ZK proof on a user's device?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"Is ZK authentication production-ready in 2026?","acceptedAnswer":{"@type":"Answer","text":"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)."}}]}]}],"contentHash":"1df0c54f147158bbb5b55a09c2282447e0e67c320278d3391eb19743e103de54","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/zero-knowledge-proofs-web3-authentication","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":67692,"contentBytes":14965}}
