Proof of Membership
In a Proof of Membership, a prover demonstrates that they know a secret corresponding to one element of a public set of commitments, without revealing which specific commitment matches their secret.
This primitive plays a central role in privacy-preserving systems. For instance, it is used in cryptocurrency mixers such as Tornado Cash to break the link between deposits and withdrawals.
The core idea is simple:
- When a user makes a deposit, they generate a secret and publish only its commitment.
- Later, to withdraw funds, they prove that they know the secret corresponding to one of the published commitments.
- Crucially, the proof reveals neither the secret nor which commitment it corresponds to.
This allows any valid withdrawal to be linked to the set of deposits, but not to a particular one.
However, this immediately raises a challenge: what prevents the same deposit from being used multiple times? This is addressed using a nullifier scheme, which we introduce below.
In a nutshell, a practical Proof of Membership combines two ingredients:
- A zero-knowledge inclusion proof: showing that a commitment is part of a public set.
- A nullifier scheme: ensuring that the same secret cannot be used twice.
We present these two components separately before combining them.
Part I — Inclusion via Merkle Trees
Merkle Proofs Recap
To prove that a value belongs to a large collection efficiently, we typically use a Merkle tree:
- The leaves are the commitment hashes
- Internal nodes are hashes of their children
- The root uniquely commits to the entire set
A Merkle inclusion proof allows one to show that a specific leaf belongs to the tree by revealing only:
- the leaf value
- the sibling hashes along the path to the root
A Merkle inclusion proof complexity is logarithmic in the size of the set, rather than linear.
From Merkle Proofs to Zero Knowledge
A classical Merkle proof is not zero knowledge, since the leaf (i.e., the commitment hash) is explicitly revealed.
To preserve privacy, we embed the Merkle inclusion check inside a SNARK circuit and prove it in zero knowledge.
The resulting statement becomes:
Statement: I know a
secretsuch that the commitmentH(secret)is included in the Merkle tree with rootmerkleRoot.
Formally:
Statement: I know a
secretsuch thatC(secret, merkleRoot) = true
where:
secretis a private witnessmerkleRootis a public value committing to the full set
This proves membership without revealing the committed value itself.
Part II — Preventing Double Use with Nullifiers
The Problem
If we only prove inclusion, a verifier cannot tell whether two distinct proofs refer to the same underlying secret or to different ones. This opens the door to double spending or multiple withdrawals using the same deposit.
The Nullifier Idea
To solve this, we introduce a nullifier.
A nullifier is a cryptographic value deterministically derived from the secret that enables verifiers to detect reuse of the same secret without learning anything about the secret itself.
Instead of committing only to secret, we commit to the pair:
commitment = H(secret, nullifier)
This yields the new statement:
Statement: I know a
secretand anullifiersuch that the commitmentH(secret, nullifier)is included in the Merkle tree.
Formally:
Statement: I know
secretandnullifiersuch thatC(secret, nullifier, merkleRoot) = true
with:
secret: private witnessnullifier: revealed when provingmerkleRoot: public tree root
Why This Prevents Double Use
When producing a proof, the prover is forced to reveal the nullifier.
If a verifier maintains a record of all nullifiers already seen, then any attempt to reuse the same secret will reveal the same nullifier and the verifier can immediately reject repeated usage
This ensures one-time use per secret, while preserving anonymity among all commitments.
Using the ZK‑Toolbox (Developer View)
Using our zk‑toolbox library a commitment is defined as:
commitment = Poseidon(secret, nullifier)
Inputs and Outputs
- Private input:
secret: the value whose membership is being proven
- Public inputs:
commitments: the list of all commitments (Merkle tree leaves)nullifier: ensures one-time usenonce: binds the proof to a specific context
- Public output:
authHash: binds the secret and nullifier to the nonce
Example
import { ProofOfMembership, randomBigInt32ModP, poseidon } from "@prifilabs/zk-toolbox";
// step 1: generate the inputs
let secret, nullifier, commitment;
const commitments = [];
const random = Math.floor(Math.random() * 100);
for (let i =0; i<100; i++){
const s = randomBigInt32ModP();
const n = randomBigInt32ModP();
const c = poseidon([s, n]);
if (random == i){
secret = s;
nullifier = n;
commitment = c;
}
commitments.push(c);
}
const nonce = randomBigInt32ModP();
const privateInputs = { secret };
const publicInputs = { commitments, nullifier, nonce };
// step 2: generate the proof
const proofOfMembership = new ProofOfMembership();
const { proof, publicOutputs } = await proofOfMembership.generate(privateInputs, publicInputs);
// step 3: verify the output (optional)
console.assert(publicOutputs.authHash == poseidon([privateInputs.secret, publicInputs.nullifier, publicInputs.nonce]));
// step 4: verify the proof
const res = await proofOfMembership.verify(proof, publicInputs, publicOutputs);
console.assert(res);
Behind the Scenes: The CIRCOM Circuit
We reuse a Merkle inclusion circuit originally developed for the Semaphore Protocol.
The circuit verifies that a leaf is correctly connected to the root via a Merkle path.
Merkle Inclusion Subcircuit
pragma circom 2.0.0;
include "../../../node_modules/circomlib/circuits/poseidon.circom";
include "../../../node_modules/circomlib/circuits/mux1.circom";
template MerkleTreeInclusionProof(nLevels) {
signal input leaf;
signal input pathIndices[nLevels];
signal input siblings[nLevels];
signal output root;
component poseidons[nLevels];
component mux[nLevels];
signal hashes[nLevels + 1];
hashes[0] <== leaf;
for (var i = 0; i < nLevels; i++) {
pathIndices[i] * (1 - pathIndices[i]) === 0;
poseidons[i] = Poseidon(2);
mux[i] = MultiMux1(2);
mux[i].c[0][0] <== hashes[i];
mux[i].c[0][1] <== siblings[i];
mux[i].c[1][0] <== siblings[i];
mux[i].c[1][1] <== hashes[i];
mux[i].s <== pathIndices[i];
poseidons[i].inputs[0] <== mux[i].out[0];
poseidons[i].inputs[1] <== mux[i].out[1];
hashes[i + 1] <== poseidons[i].out;
}
root <== hashes[nLevels];
}
Full Proof of Membership Circuit
pragma circom 2.0.0;
include "../node_modules/circomlib/circuits/poseidon.circom";
include "./lib/MerkleTree/MerkleTree.circom";
template ProofOfMembership(levels) {
// private inputs
signal input secret;
signal input siblings[levels];
signal input pathIndices[levels];
// public inputs
signal input nullifier;
signal input nonce;
// public outputs
signal output root;
signal output authHash;
// compute the commitment hash
component commitmentHasher = Poseidon(2);
commitmentHasher.inputs <== [secret, nullifier];
// compute the merkle root from Merkle Proof values
component tree = MerkleTreeInclusionProof(levels);
tree.leaf <== commitmentHasher.out;
for (var i = 0; i < levels; i++) {
tree.siblings[i] <== siblings[i];
tree.pathIndices[i] <== pathIndices[i];
}
root <== tree.root;
// context binding
component authHasher = Poseidon(3);
authHasher.inputs <== [secret, nullifier, nonce];
authHash <== authHasher.out;
}
component main {public [nullifier, nonce]} = ProofOfMembership(20);
Why We Still Use a Nonce
Even though the nullifier prevents reuse of the same secret, the nonce plays a distinct role:
- The nullifier prevents reuse of the same secret
- The nonce prevents replay of the same proof in a different context
This allows the same membership proof to be used multiple times legitimately (e.g., retries, delayed conditions), while preventing replay by observers.
Conclusion and Applications
Proof of Membership is a foundational primitive for privacy-preserving systems.
It enables:
- anonymous authentication
- mixers and privacy pools
- anonymous voting
- group membership proofs
without revealing identity or linkability between actions.
Conceptually, it realizes anonymous authorization: proving that someone in a group is allowed, without revealing who.
In our next chapter, Proof of Exclusion, we explore the dual problem: proving in zero knowledge that a secret does not belong to a set of commitments.