Free 30-min Web3 Consultation
Book Consultation
Smart Contract Security Audits
View Audit Services
Custom DeFi Protocol Development
Explore DeFi
Full-Stack Web3 dApp Development
View App Services
Free 30-min Web3 Consultation
Book Consultation
Smart Contract Security Audits
View Audit Services
Custom DeFi Protocol Development
Explore DeFi
Full-Stack Web3 dApp Development
View App Services
Free 30-min Web3 Consultation
Book Consultation
Smart Contract Security Audits
View Audit Services
Custom DeFi Protocol Development
Explore DeFi
Full-Stack Web3 dApp Development
View App Services
Free 30-min Web3 Consultation
Book Consultation
Smart Contract Security Audits
View Audit Services
Custom DeFi Protocol Development
Explore DeFi
Full-Stack Web3 dApp Development
View App Services
LABS
Guides

Setting Up a Whitelist System with Privacy-Preserving Verification

This guide details the implementation of a whitelist where eligibility is proven without revealing the whitelist itself or the user's specific entry. It utilizes cryptographic primitives like zero-knowledge proofs or commitment schemes to allow users to generate a proof of inclusion. The smart contract verifies this proof to grant access, ensuring the list remains private and resistant to front-running.
Chainscore © 2026
introduction
GUIDE

Setting Up a Whitelist System with Privacy-Preserving Verification

This guide explains how to build a whitelist that verifies user eligibility without exposing their private data, using zero-knowledge proofs and Merkle trees.

A traditional whitelist system requires users to submit their wallet address for verification, creating a public list of approved participants. This exposes user data and can lead to targeted attacks or unfair advantages. A privacy-preserving whitelist solves this by allowing users to prove they are on an approved list without revealing which specific entry is theirs. This is typically achieved using cryptographic primitives like Merkle trees and zero-knowledge proofs (ZKPs), enabling private verification for token sales, NFT mints, or gated access.

The core component is a Merkle tree. The whitelist manager hashes each eligible user's address (or a commitment to it) and builds a Merkle tree from these hashes. The resulting Merkle root is published on-chain. To verify, a user must provide a Merkle proof—a path of hashes from their leaf to the public root. However, a standard Merkle proof reveals the user's leaf data. To add privacy, we use a ZKP, such as a zk-SNARK, where the user proves they know a valid leaf and corresponding Merkle path for the public root, without disclosing the leaf itself.

Here's a simplified conceptual flow using the circom circuit language and snarkjs. First, define a circuit that verifies a Merkle proof. The private inputs are the user's secret (like a nullifier) and the Merkle path. The public input is the published Merkle root.

circom
// Pseudo-circuit for Merkle proof verification
template WhitelistVerifier(levels) {
    signal private input leaf;
    signal private input pathElements[levels];
    signal private input pathIndices[levels];
    signal input root;
    // ... circuit logic to compute root from leaf & path
    root === computedRoot;
}

The user generates a proof off-chain and submits only the proof and public root to the smart contract.

The on-chain verifier contract is simple and gas-efficient. It stores the valid Merkle root and has a verifyAndMint function that checks the ZKP. A crucial addition is a nullifier to prevent double-spending. The user's private input includes a unique nullifier hash; the contract records spent nullifiers to block reuse. Popular libraries for implementation include Semaphore for identity proofs or zkKit for Merkle tree utilities. This pattern is used by protocols like Tornado Cash for anonymity and zkSync's native account abstraction for transaction privacy.

When implementing, consider the trade-offs. Generating ZKPs requires off-chain computation, which can be a user experience hurdle. Solutions include integrating ZK proof relayers or using proof aggregation. Furthermore, the trust model shifts: users must trust the whitelist manager to construct the tree correctly and not to track them via the nullifier if it's not generated properly. For maximum decentralization, the eligibility criteria itself (e.g., token holdings) can be verified on-chain in the ZK circuit, removing the need for a centralized list entirely.

To get started, use a framework like @zk-kit/incremental-merkle-tree to manage off-chain trees and snarkjs for proof generation. Your smart contract will import a verifier Solidity file generated from your circuit. The final system enables gated access where the contract only confirms "a valid, unspoken-for member of the whitelist is executing this," preserving user privacy, reducing front-running risk, and adhering to principles of minimal data exposure on the blockchain.

prerequisites
WHITELIST IMPLEMENTATION

Prerequisites and Setup

This guide outlines the technical requirements and initial setup for building a whitelist system that uses zero-knowledge proofs for privacy-preserving verification.

A privacy-preserving whitelist system allows users to prove they are on an approved list without revealing their identity or the list's contents. The core prerequisites are a zero-knowledge proof (ZKP) framework and a verification smart contract. For this guide, we'll use Circom for writing ZKP circuits and SnarkJS for proof generation and verification. You'll need Node.js (v18+) and npm installed. Start by creating a new project directory and installing the essential packages: npm init -y, npm install circomlib snarkjs. These tools provide the cryptographic primitives and utilities needed to construct the proving system.

The system's logic is defined in a Circom circuit. This circuit takes a private input (the user's secret identifier) and a public input (the Merkle root of the whitelist). It checks if a hash of the private input exists within the Merkle tree. First, you must compile the circuit. Using the Circom compiler, run circom whitelist.circom --r1cs --wasm --sym. This command generates the R1CS constraint system, WebAssembly files for witness generation, and a symbols file for debugging. These artifacts are necessary for the subsequent trusted setup phase, where you generate the proving and verification keys.

Next, perform the trusted setup using SnarkJS. This multi-step process creates the cryptographic parameters (proving key proving_key.zkey and verification key verification_key.json) that secure the system. Execute: snarkjs powersoftau new bn128 12 pot12_0000.ptau, contribute randomness with snarkjs powersoftau contribute..., and then snarkjs groth16 setup with your circuit's R1CS file. Finally, export the verification key: snarkjs zkey export verificationkey verification_key.json. This key will be used by your smart contract. Important: For production, use a secure multi-party ceremony (like Perpetual Powers of Tau) instead of this local, single-party setup.

The verification logic must be deployed on-chain. You'll write a Verifier.sol contract using the Solidity code exported from SnarkJS: snarkjs zkey export solidityverifier. This contract contains a verifyProof function that accepts the proof and public inputs. Deploy this contract to your chosen EVM network (e.g., Sepolia, Arbitrum). The contract address and the public Merkle root become the system's on-chain state. Users will generate their proofs off-chain against this root and submit them to the verifier contract to gain access.

To manage the whitelist, you need an off-chain process to build and update the Merkle tree. Use a library like merkletreejs and circomlib's poseidon hash function (commonly used in ZK circuits for efficiency). Create a script that takes a list of allowed identifiers (e.g., commitment hashes), builds the tree, and calculates the root. Store this root in your verifier contract via a privileged function. You must also provide users with their specific Merkle proof (the sibling hashes needed to verify their leaf's inclusion) which they will use as a private input to generate their ZK proof.

key-concepts-text
CORE CRYPTOGRAPHIC CONCEPTS

Setting Up a Whitelist System with Privacy-Preserving Verification

Learn how to build a secure, private whitelist using zero-knowledge proofs, moving beyond simple on-chain address lists.

Traditional whitelist systems expose sensitive data. Storing user addresses or eligibility criteria directly on-chain creates a public list of participants, compromising privacy and potentially enabling front-running or targeted attacks. A privacy-preserving whitelist solves this by using cryptographic proofs to verify a user's right to access a resource—like a token mint or airdrop—without revealing their identity or the specific criteria they satisfied. This approach is essential for compliant systems (e.g., KYC) and competitive environments where participant anonymity is valuable.

The core mechanism enabling this is a zero-knowledge proof (ZKP), specifically a zk-SNARK. Here's the workflow: 1) A trusted issuer (like a backend server) holds the master whitelist and a secret key. 2) For each eligible user, the issuer generates a cryptographic credential—a ZKP that proves the user is on the list, signed with the secret key. 3) The user submits this proof to a smart contract. 4) The contract verifies the proof's validity against the issuer's public verification key, confirming eligibility without learning the user's identity or the secret list.

To implement this, you need a circuit written in a ZKP domain-specific language like Circom or Noir. This circuit defines the statement to be proven: "I possess a signature from the issuer's private key for my nullifier." A nullifier is a unique, deterministic hash (e.g., hash(secret, address)) that allows the contract to prevent double-spending of the credential without linking multiple transactions to the same user. The circuit output is a proof that the prover knows a valid signature for their nullifier, which the verifier contract checks using elliptic curve pairing operations.

Your smart contract requires a verifier function, typically auto-generated by the ZKP toolkit from your circuit. For a Circom/Groth16 setup, the contract would have a function like function mint(bytes calldata _proof, uint256 _nullifierHash) public. It would verify the proof using a verifyProof() call and then check if _nullifierHash has been used before, storing it in a mapping if not. The issuer's role is off-chain, using their private key to generate the Semaphore-style signatures or RLN credentials that users need to create their proofs.

Key considerations for production include the security of the issuer's private key, which must be managed carefully (e.g., using multi-sig or distributed key generation). You must also decide on the credential's data structure: will it include selective disclosure of attributes (e.g., tier level) using zk-proofs of membership in a Merkle tree? Frameworks like Semaphore, ZK-Kit, and RLN (Rate-Limiting Nullifier) provide robust libraries for these patterns, abstracting much of the cryptographic complexity.

This architecture provides strong privacy and security but introduces gas costs for on-chain verification and complexity in key management. It's ideal for high-stakes whitelists for NFT mints, token distributions, or gated DAO votes where participant privacy is non-negotiable. By leveraging ZKPs, you can build compliant, fair, and private access systems that are verifiable by all without leaking sensitive data to the public blockchain.

architecture-components
WHITELIST IMPLEMENTATION

System Architecture and Components

A secure whitelist system requires multiple components working together. This section covers the core architecture, from on-chain registries to privacy-preserving verification methods.

01

On-Chain Registry & Merkle Trees

The most common on-chain whitelist patterns are a mapping of addresses to a boolean flag or a Merkle tree root stored in the contract. Merkle proofs allow users to verify inclusion without the contract storing all addresses, reducing gas costs. For example, an NFT mint contract might store a single bytes32 merkleRoot and verify proofs using MerkleProof.verify().

  • Mapping: Simple but expensive for large lists.
  • Merkle Tree: Efficient, privacy-preserving for users, but requires off-chain proof generation.
02

Off-Chain Signatures (EIP-712)

A server holds the whitelist and signs a structured message (EIP-712) for verified users. The user submits this signature to the smart contract, which recovers the signer's address to validate. This keeps the list completely private off-chain and allows for dynamic updates without on-chain transactions.

  • Uses ecrecover or OpenZeppelin's ECDSA library.
  • The signing authority must be a secure, trusted backend server.
  • Revocation requires invalidating signed messages, often by using a nonce.
03

Zero-Knowledge Proof Verification

For maximum privacy, users can generate a zero-knowledge proof (e.g., using Circom and SnarkJS) that proves their address is in a whitelist without revealing which one. The contract only needs to verify the proof against a trusted public setup. This is complex but enables applications like private airdrops or voting.

  • Frameworks: Circom, snarkjs, Halo2.
  • The verifier contract is often generated from a circuit.
  • Requires a trusted setup ceremony for some proving systems.
04

Access Control & Role Management

Managing who can update the whitelist is critical. Use OpenZeppelin's AccessControl or Ownable patterns to restrict functions like setMerkleRoot or setSigner. Implement a multi-sig or timelock for high-value contracts.

  • Ownable: Simple single-owner model.
  • AccessControl: Flexible role-based system (e.g., WHITELIST_ADMIN_ROLE).
  • Consider adding a pause mechanism to disable minting if a vulnerability is found.
05

Gas Optimization Techniques

Whitelist checks happen during high-gas functions like minting. Optimize to save user costs.

  • Pack booleans: Use uint256 as a bitmap to store 256 whitelist slots in one storage slot.
  • Merkle proofs in calldata: Pass proofs as bytes32[] calldata to use cheaper memory.
  • Verify early: Perform the whitelist check at the start of the function to fail fast and save gas.
  • For signatures, avoid storing used nonces in a mapping; a bitmap is more efficient.
06

Testing & Security Considerations

Comprehensive testing is required to prevent exploits like signature replay or Merkle tree forgery.

  • Tests: Write Foundry or Hardhat tests for valid/invalid proofs, signatures, and edge cases.
  • Replay Attacks: Protect signed messages with a chain ID and contract address in the EIP-712 domain, and a user-specific nonce.
  • Front-running: Consider commit-reveal schemes if whitelist status should be hidden until reveal.
  • Audit: Critical for systems holding significant value. Review common vulnerabilities from OpenZeppelin's audit reports.
step-1-commitment
FOUNDATION

Step 1: Building the Private Commitment

This step establishes the core privacy mechanism. We create a system where a user can cryptographically commit to being on a whitelist without revealing their identity on-chain.

A private commitment is a cryptographic proof that a user is authorized, derived from a secret they hold, without exposing that secret or their public identity. The core tool for this is a Merkle tree. The whitelist manager generates a Merkle tree where each leaf is a cryptographic hash of a user's secret, such as keccak256(address, secretSalt). The resulting Merkle root—a single 32-byte hash representing the entire list—is published on-chain. Users are then given their specific leaf value and a Merkle proof (the sibling hashes needed to reconstruct the root) off-chain, keeping their actual address and salt private.

The on-chain contract only stores the public Merkle root. To verify membership, a user submits a transaction that includes their leaf hash and Merkle proof. The contract's verify function recomputes the root from this data and checks it against the stored root. A match proves the user's leaf was part of the original authorized set. Crucially, the leaf hash itself reveals nothing about the user's Ethereum address; it's just a random-looking 32-byte number. This achieves privacy for the verifier, as on-chain observers cannot link the verification transaction to a specific whitelisted identity.

Implementing this requires careful off-chain generation. Using a library like OpenZeppelin's MerkleProof, the process is straightforward. First, generate the leaves: leaf = keccak256(abi.encodePacked(account, salt)). Then, build the tree and get the root. The proof for a leaf is generated using the library's getProof function. The Solidity verification uses MerkleProof.verify(proof, root, leaf). It is critical that the abi.encodePacked method matches exactly between the generation script and the contract to avoid verification failures due to hash collisions.

This approach has significant advantages over a public list of addresses. It reduces gas costs, as storing and checking a single root is cheap. It also prevents front-running and targeting, as an attacker monitoring the mempool cannot identify which whitelisted user is about to claim an asset. However, it introduces an off-chain dependency: the whitelist manager must securely distribute the secret salts and proofs to users. If the salt is predictable (e.g., just the address), the privacy guarantee collapses, as anyone can compute the leaf and de-anonymize the user.

step-2-circuit
CIRCUIT LOGIC

Step 2: Designing the ZK Circuit

This step defines the core logic that proves a user is on a whitelist without revealing their identity or the list itself.

A zero-knowledge circuit is a program written in a domain-specific language (DSL) like Circom or Noir. It defines a set of constraints that a valid proof must satisfy. For a whitelist, the primary constraint is: the user's secret identifier must hash to a value that exists within a private set of commitments. The circuit itself only contains the public logic; the actual whitelist data (the Merkle root) is a public input, while the user's secret data (the leaf and its Merkle path) are private inputs.

The circuit performs several cryptographic checks. First, it hashes the user's private secret (e.g., a nullifier) to generate the leaf. It then verifies that this leaf is a valid member of the Merkle tree by reconstructing the root from the leaf and the provided Merkle path (a series of sibling hashes). The computed root must equal the publicly known merkleRoot. This proves membership without disclosing which specific leaf was used.

A critical addition is the nullifier. To prevent double-spending a whitelist spot, the circuit also outputs a public nullifierHash, computed as hash(secret, merkleRoot). This unique value is recorded on-chain. If a user tries to generate another proof with the same secret, it will produce the same nullifier hash, which the smart contract will reject. This ensures one-time use per whitelisted entry.

Here is a simplified conceptual structure for a Circom circuit template:

circom
template WhitelistVerifier(levels) {
    // Public inputs
    signal input merkleRoot;
    signal input nullifierHash;
    // Private inputs
    signal input secret;
    signal input pathElements[levels];
    signal input pathIndices[levels];
    // Compute leaf from secret
    signal leaf <== PoseidonHash(secret);
    // Compute Merkle root verification
    signal computedRoot <== MerkleTreeVerifier(leaf, pathElements, pathIndices);
    // Constraint: computed root must match public root
    computedRoot === merkleRoot;
    // Compute and output nullifier hash
    nullifierHash <== PoseidonHash(secret, merkleRoot);
}

This template uses the Poseidon hash, a ZK-friendly function, for efficiency.

After defining the circuit, you compile it to generate two key artifacts: the R1CS (Rank-1 Constraint System), which represents the arithmetic circuit, and the proving/verification keys. The proving key is used by users to generate proofs, while the verification key is embedded into the smart contract to validate them. Tools like snarkjs (for Circom) or the native Noir compiler handle this compilation and setup phase, which can be performed in a trusted ceremony or using secure multi-party computation for production systems.

step-3-contract
IMPLEMENTATION

Step 3: Deploying the Verifier Contract

This step deploys the core smart contract that verifies zero-knowledge proofs to manage a privacy-preserving whitelist.

The Verifier contract is the on-chain component that validates zero-knowledge proofs. It contains a single critical function, verifyProof, which accepts the cryptographic proof and public inputs generated by the user's client. This function executes the verification key embedded during compilation. If the proof is valid and corresponds to an approved Merkle root (stored as a public input), the function returns true. Deploying this contract makes the verification logic immutable and publicly accessible on the blockchain.

Before deployment, you must compile the circuit to generate the verification key. Using the snarkjs library, run snarkjs zkey export verificationkey circuit_final.zkey verification_key.json. This creates a JSON file containing the key. The next command, snarkjs zkey export solidityverifier circuit_final.zkey verifier.sol, generates the Solidity contract code with this key hardcoded inside it. This verifier.sol file is the contract you will deploy.

Deploy the generated Verifier.sol contract using a tool like Hardhat, Foundry, or Remix. The contract has no constructor arguments, making deployment straightforward. Once live, note the contract address—this is the verifier contract address that your main application contract (e.g., a token sale or NFT mint) will call. The security of your entire whitelist system depends on this contract, as it is the single source of truth for proof validation.

In your application's main contract, you will need a function that calls the verifier. A typical implementation includes a mapping like mapping(address => bool) public hasMinted; to prevent double-spending. The mint function would require the proof and public inputs as parameters, call verifier.verifyProof(...), check the result, verify the caller hasn't minted before, and then execute the mint logic. This separates the complex cryptography from the business logic.

For production, consider using a proxy upgrade pattern for the Verifier contract if you anticipate circuit updates. However, note that changing the circuit requires generating a new verification key and a new Verifier contract deployment. Your main contract would need a mechanism (e.g., controlled by a multisig) to update the verifier address it references. Always test thoroughly on a testnet like Sepolia or Goerli before mainnet deployment.

step-4-integration
IMPLEMENTATION

Step 4: Front-End Integration and User Flow

This guide details how to build a front-end interface that securely interacts with a privacy-preserving whitelist system, managing the user flow from verification to transaction.

The front-end is the user's gateway to your whitelist system. Its primary functions are to initiate the verification proof generation, submit the proof to the on-chain verifier contract, and execute the gated transaction upon successful verification. You'll need a Web3 library like ethers.js or viem to connect the user's wallet, call smart contract functions, and listen for events. The core challenge is orchestrating the sequence: 1) User connects wallet, 2) Front-end requests a ZK proof from your backend prover service, 3) User submits the proof on-chain, and 4) The contract's access control allows the subsequent mint or swap.

To request a proof, your React/Vue/Svelte app must send the user's public address (e.g., userAddress) to a secure backend endpoint. This endpoint runs the proving logic (using tools like SnarkJS or Circom) with the private whitelist Merkle root and the user's leaf data. It returns a ZK proof (like a Groth16 proof) and any necessary public signals. Never perform proof generation client-side, as it would expose the private whitelist criteria. The front-end receives this proof object and prepares it for the on-chain transaction.

Submitting the proof requires calling the verifier contract's function, typically named verifyAndExecute. Using ethers.js, the call would look like: await verifierContract.verifyAndExecute(proof.a, proof.b, proof.c, publicSignals). The contract's verify function internally checks the proof against the verifier key. If valid, it sets a state flag (e.g., isVerified[msg.sender] = true) or emits an event. Your front-end must listen for the transaction confirmation and the resulting Verified event before enabling the gated action button.

Design the user flow to handle all states: wallet-not-connected, proof-requesting, awaiting-proof, ready-to-verify, verifying-on-chain, verified-success, and failed. Provide clear feedback at each step. For a better UX, you can use the SIWE (Sign-In with Ethereum) standard to establish a session after verification, preventing users from needing to re-prove for subsequent actions. Always include a link to a block explorer like Etherscan for the verification transaction to build trust through transparency.

Security on the front-end revolves around managing sensitive data flows. The whitelist Merkle root is public on-chain, but the individual leaves (user eligibility data) should remain confidential between the user and your backend. Use HTTPS for all API calls to your prover service. Consider implementing rate limiting on the proof endpoint to prevent abuse. Finally, your smart contract should include a commit-reveal scheme or allow the admin to rotate the Merkle root to invalidate old proofs if the whitelist needs to be updated.

ARCHITECTURE COMPARISON

Traditional vs. Private Whitelist Systems

Key differences in design, security, and user experience between public on-chain and privacy-preserving whitelist models.

Feature / MetricTraditional On-Chain WhitelistPrivacy-Preserving Whitelist

Verification Data Visibility

Public on-chain for all users

Zero-knowledge proof only

User Address Privacy

Gas Cost per Verification

$5-15 (Ethereum mainnet)

$0.50-2.00 (zk-proof generation)

Front-Running Risk

Sybil Attack Resistance

Low (public list analysis)

High (private attestation)

Integration Complexity

Low (simple mapping)

Medium (requires zk-SNARK verifier)

Compliance (GDPR/KYC)

Typical Use Case

Public NFT mint, token airdrop

Private deal room, institutional allocation

WHITELIST IMPLEMENTATION

Frequently Asked Questions

Common technical questions and solutions for developers building privacy-preserving whitelist systems using zero-knowledge proofs (ZKPs) and related cryptographic primitives.

A privacy-preserving whitelist is a system that allows a verifier (e.g., a smart contract) to confirm a user is authorized without revealing their identity or any other user's identity on the list. It uses cryptographic proofs, primarily zero-knowledge proofs (ZKPs), to achieve this.

Core Mechanism:

  1. List Commitment: The whitelist manager creates a cryptographic commitment (like a Merkle root) to the authorized list of identities (e.g., hashed addresses). This root is stored on-chain.
  2. User Proof Generation: An authorized user, using their private data, generates a ZK proof (e.g., a zk-SNARK) that proves:
    • They know a secret (their identity/address).
    • The hash of that secret is a valid leaf in the committed Merkle tree.
    • They are not revealing the secret or any other leaf.
  3. On-Chain Verification: The user submits only the proof to the smart contract. The contract verifies the proof against the stored Merkle root. A successful verification grants access while leaking zero information about who the user is relative to others on the list.
conclusion
IMPLEMENTATION SUMMARY

Conclusion and Next Steps

You have successfully implemented a privacy-preserving whitelist system using zero-knowledge proofs (ZKPs). This guide covered the core concepts and a practical Solidity and Circom example.

The system you built demonstrates a fundamental pattern: off-chain proof generation and on-chain verification. Users prove membership in a private Merkle tree without revealing their specific leaf data. The WhitelistVerifier.sol contract only needs the public root and the ZK proof, ensuring the whitelist itself remains confidential. This architecture is foundational for applications requiring privacy in access control, such as private token sales, gated NFT mints, or exclusive DAO proposals.

To extend this system, consider these practical improvements. First, implement a mechanism to update the Merkle root securely, perhaps via a multi-signature wallet or DAO vote, to allow for dynamic whitelist management. Second, add proof nullification to prevent reuse; store a mapping of nullifiers derived from the secret in your circuit to mark a proof as spent. For production, integrate a relayer service to pay gas fees for users, making the process completely gasless and seamless.

The next logical step is to explore more advanced ZK constructs. Look into Semaphore for anonymous signaling or zkSNARKs libraries like snarkjs and circomlib for more complex logic. For deeper learning, review the documentation for circom and snarkjs, and study real-world implementations like the zkopru rollup or tornado-cash-nova for privacy pool concepts. The code from this guide is available on the Chainscore Labs GitHub.