The Secret Ingredient That Makes ZK Anonymity Actually Work
Salah Ismail·September 29, 2026·
Let's say you deposit 1 ETH into a mixer. Later, you go back to the mixer and prove that you are one of the depositors and the mixer verifies your proof and gives you the money you deposited, but the verifier can't tell from which deposit your withdrawal is linked to. So what stops you from submitting the same proof again? The answer is the nullifier.
In this article, we break down what nullifiers are and the properties that make them work, walk through how real protocols like Tornado Cash, Zcash, Penumbra, and more, implement them, and finally close with a field guide to the vulnerability classes that show up when nullifier design goes wrong, backed by real audit findings.
1-minute introduction to ZK
Before talking about nullifiers, let's briefly introduce the context where it is used: Zero-Knowledge (ZK) applications.
A ZK system lets someone prove that a statement is true without revealing why it is true. E.g. "The encrypted transaction I'm submitting follows the protocol rules" without providing the transaction details, or "I'm part of that list" without revealing your identity.
In such systems, we usually find at least two actors: the prover, who provides the proof to the statement, and the verifier, who ensures the proof is valid.
Practically, this works by expressing the statement and its computations as an arithmetic circuit, a set of mathematical constraints operating over the inputs and the different steps of the computation. Inputs come in two kinds:
- the public inputs, visible to anyone and checked by the verifier.
- the private outputs, known only to the prover, and only used during the proof generation.
This is what makes private blockchain applications possible: the chain enforces the rules of the system without ever seeing the data.
Why a nullifier
Usually, public inputs do not reveal anything about the prover, otherwise the privacy property is lost. But then, a problem appears: since every valid proof looks like any other, how can the verifier tell whether a prover is doing something for the first time, or again?
On a transparent chain like Bitcoin, validators can see exactly which coins a transaction consumes and simply check that they haven't been consumed before.
In Tornado Cash for example, users deposit from an address A and withdraw from an address B, but how can B prove it is authorized to withdraw without revealing the deposit it has made with address A? And how can the system ensure the withdrawal can only happen once? Similarly, in a voting system, how can a user cast a vote and prove that they have not previously voted in the same election, without revealing their vote?
The nullifier is the solution to this problem: a value, that is published as a public input, that uniquely tags an action as "already done" without revealing anything about the action itself. By storing every submitted nullifier, the verifier can then easily reject duplicates, and anonymity is kept.
Definition and properties
A nullifier is a unique and deterministic value that tags an action as already executed, without revealing the action. It is usually computed with a hash function over the action's identifying data and an additional secret.
Nullifiers can be of different form, as far as they respect those following properties:
- deterministic: same inputs should always give the same nullifier, and bind it to its inputs. If a user can derive two different valid nullifiers for one action, they can execute it twice.
- unique/collision-resistant: each nullifier must exactly map to 1 set of inputs. If two distinct actions can share a nullifier, one legitimate action can block another.
- one-way/hiding: given a nullifier, it should be impossible to do the inverse operation (find the inputs). Otherwise the published nullifier deanonymizes the action.
In a blockchain context, three more are usually required:
- non-interactive: the prover computes the nullifier and its proof alone, with no back-and-forth with the verifier.
- efficient: cheap for the verifier to check (as it runs on-chain).
- no private key exposed: the private shouldn't leave its secure enclave and be used as one of the circuit's inputs.
Building blocks
Beyond the ZK proof itself that we (very) briefly explained at the beginning of the article, two more definitions will be required to follow the next sections:
- Hash function: a function that maps any input to a fixed-size output, with three properties that everything below relies on: deterministic, one-way and collision-resistant. Notice how the similarity with the property of a nullifier: it is why hashing the action's data together with a secret is the default nullifier construction, the hash transfers its properties to the nullifier.
- Commitment: a mathematical scheme that allows locking in a specific value or piece of data while keeping it entirely hidden from others. Usually based on hash function, which has the property to be irreversible.
- Merkle tree: a structure that compresses a large set of commitments into a single value called the root hash. Proving that a commitment is somewhere in the tree only requires a subset of the hashes it contains.
A concrete walkthrough: Tornado Cash

Tornado Cash is a privacy protocol (a "mixer") on Ethereum: users deposit funds into a shared pool, wait, then withdraw them to a fresh address, breaking the link between the two. One design detail is important to understand upfront: each pool only accepts deposits of a single fixed value (0.1, 1, 10 or 100 ETH). If arbitrary amounts were allowed, an observer could simply match a 13.37 ETH deposit to a 13.37 ETH withdrawal, breaking the anonymity. With fixed amounts, withdrawals become indistinguishable from the other ones in the pool, and the anonymity set is the full set of all depositors.
Deposit
When depositing funds, the user generates two random values, a secret and a nullifier seed, and inserts commitment = hash(secret, nullifier_seed) as a leaf in the protocol's Merkle tree. The hash cannot be reversed, so the commitment reveals neither value. Both values are just independent random numbers, what differs is their role: the secret only blinds the commitment and is never revealed in any form, while the seed's hash will be published at withdrawal as the nullifier.
Naming warning: Tornado's own terminology differs from the norm. What its code calls the "nullifier" is the private random value (our nullifier seed), and what gets published at withdrawal is called the nullifier hash (which is the hash of the nullifier seed). In this article, "nullifier" always refers to the public tag (Tornado's nullifier hash), and "nullifier seed" to the private preimage.
Withdrawal
When withdrawing the users proves (in ZK) that:
- they know a (secret, nullifier_seed) pair whose commitment is a (any) leaf of the tree.
- public input: merkle root, merkle path
- private inputs: secret, nullifier seed
- the nullifier (i.e. "nullifier hash" in Tornado Cash terminology) they are publishing alongside the proof is the hash of that same nullifier seed.
- public input: nullifier
- private input: secret, nullifier seed
The contract checks the proof, confirms the pair is one of the existing leaves, checks the nullifier hasn't been seen before, records it, and releases the funds.
So, why can't the user just reveal the commitment instead? The reason is that the commitment is linked to the deposit transaction, and therefore to the depositor's address.
The nullifier is the unlinkable alias of the commitment: it deterministically identifies the deposit (preventing double-spending), but nobody can connect the two. Thanks to this, the user is able to withdraw the deposit only once, while staying anonymous.
This is one example of a nullifier use case and implementation, but there are many others that we will present briefly in the next sections.
Other nullifiers implementations
Private Transaction: ZCash

In UTXO systems (Bitcoin being the public, non-private original one), money is represented as "notes": each transaction consumes the whole input notes, and creates new output notes, and every note can be spent exactly once. Private UTXO systems like Zcash keep this model, but replace public, readable notes with commitments in a tree.
A note can only be spent once, so a nullifier is tied to this specific note. To spend the note, the user must prove in ZK that:
- the note's commitment is in the tree
- the published nullifier matches the note
- they own the private key associated with that note
To clarify: the user never puts their actual private key (in ZCash terms, the spending key) inside the proof. From this key, the wallet derives smaller dedicated keys, each with a single job. One of them, the nullifier key nk, computes nullifiers. Another one proves the right to spend.
Private swaps: Penumbra
Penumbra is a Layer 1 blockchain, and a shielded DEX built upon multi-asset shielded pools: balances and positions are private notes (amount, asset and owner are hidden), whose commitments are recorded on-chain and public.
Penumbra inherits from ZCash's design: compared to Tornado Cash, where every new deposit uses a different nullifier seed, the same nullifier key nk is used across all the user's notes and known only to the user, as it is derived from their own "private key", allowing the user to interact with the protocol without having to expose it. The nullifier is hash(nk, position, note_commitment), where position basically encodes the epoch, block within that epoch, and index within that block (remember that Penumbra is also a Layer 1 chain). The position is important as it allows two notes with the same content (e.g. Alice swaps 1 ETH for 1 WETH) to still get different nullifiers.
Each trade or position is represented as a note, and swaps are executed privately. Then, to be able to claim the result of the trade, users must prove they own it. Claiming it reveals the nullifier, ensuring each swap is only claimed once.
More details on Penumbra's implementation of the nullifiers here.
Proof of Solvency
Anonymity is great, but how can an exchange prove it really holds users funds if both the addresses and amounts are kept secret?
A CEX wants to prove it controls assets exceeding its liabilities without revealing its addresses.
One way to do this (simplified): prove control of an address holding more than 1000 ETH (which would be close to its liabilities) at a given block, without revealing which. A snapshot of all such addresses goes into a public Merkle tree, and the CEX proves its inclusion in this tree.
The issue: two CEXs could collude together and prove solvency using the same address. Since everything happens inside a ZK proof, with private inputs (where the address would be one of those), nobody would be able to tell from the outside whether they reused the same proof material. A nullifier fixes this by ensuring that each address can only be used once.
Notice what changed compared to the previous examples: there is no deposit, no note creation, nothing where the user has to mint a fresh secret. The reason is simple: we want the set of eligible addresses to be as large as possible (all addresses holding more than 1000 ETH), but requiring each (or even most) of them to generate a commitment is unrealistic. Thus, the nullifier has to be derived without requiring each address to interact with the protocol.
The idea is to create a nullifier, which will be probably linked to an address in the tree, without revealing to which address it corresponds. Let's try to build such a scheme and explore the challenges that arise along the way (credit to this article from Summa):
H(address): (e.g. Ethereum address) unique, but not hiding. The anonymity set is a public list of all addresses with a balance greater than 1000 ETH, so an attacker can easily hash every of these addresses and unmask each member.H(address, secret): hiding (the secret makes the pre-images impossible to enumerate), but not unique. The secret has no connection to the address, so the prover can use a new secret for every proof and mint a fresh nullifier each time.H(address, secret)withH(secret)committed in the tree: works, because the prover will also have to prove the secret is the one committed during the setup phase. But this requires every member to first publish their commitment. Only interested parties will bother, which shrinks the anonymity set from millions of addresses down to the handful that opted in.H(private key): unique, hiding, no setup. But now the private key must enter the circuit as an input, meaning it leaves the user's secure enclave or hardware wallet, exactly what key management is designed to prevent.H(signature): the signature proves ownership and is produced inside the enclave, so the key never leaves the device. The problem: ECDSA signatures use a random value called the ephemeral key, so one key can produce an infinite number of valid signatures, hence as many nullifiers. See StealthDrop in the vulnerabilities section for more details.H(public key): At first sight, this should fail exactly like the first example,H(address), both values seem public. But the difference is what the attacker can enumerate. The tree contains addresses, not public keys. On Ethereum, the address is just the last 20 bytes of the 40-byte public key, and hashing is one-way, so there's no way to find the public key from an address. This holds on one condition, though: the address must never have signed a transaction or message. If it had, the public key becomes recoverable from that signature usingecrecover, putting us right back in the same situation as the first example.
Each example fails at least one of the requirements: unique, hiding, no setup phase, and no private key exposed.
One construction that satisfies all of them is PLUME: a verifiably deterministic signature scheme on ECDSA, producing exactly one nullifier.
Voting
The idea is simple: one voter, one ballot, and we must keep the voter anonymous.
If we take Semaphore as an example, the nullifier is derived from the election ID, the voter's identity, and a secret, such as derive(election_ID, identity_secret). The voter proves membership in the election's eligibility tree and publishes the nullifier with their ballot.
The election ID ensures that a different nullifier can be derived for every election, and the identity secret is derived from the user's private key, ensuring only they are able to prove membership.
Rate Limiting
RLN (Rate Limiting Nullifier) allows a number of anonymous actions per epoch, basically an anti-spam system:
- Can be used to allow free anonymous TXs on a network up to a limit
- Can be used in anonymous chat app to prevent spamming
Based on Shamir Secret Sharing (SSS) scheme, where the limit is the threshold at which the secret can be reconstructed. Users separate their secret into n parts, and for each interaction with the protocol they need to reveal one part. If they make more interactions than the configured threshold, their key can be fully reconstructed from the shared secrets, and they can then be blocked by removing them from the membership tree, which is a requirement to be able to interact with the protocol.
RLN consists of 3 phases:
- registration (stake + join the tree)
- interaction (each message reveals a share)
- removal/slashing (secret reconstructed, membership revoked, stake taken)
More details on RLN here.
Vulnerabilities
Nullifier bugs can be grouped into a few classes, each of them breaking one of the primary requirements of a nullifier: deterministic, unique and hiding.
Nondeterministic nullifier generation
If the nullifier generation is non deterministic on its inputs, or if the circuit fails to constrain them fully, it becomes possible for the same inputs to get 2 different nullifiers, which leads to the worse outcome: double spending. Here are two examples of such cases:
Aztec 2.0: When a note commitment is created, it is stored inside a merkle tree. On the note's consumption, a nullifier, linked to the note, is posted to prevent double-spending. The index of the note in the merkle tree was used in the computation of the nullifier. The circuit assumed the index to be a 32-bit integer, and so used only the last 32-bit of the provided element when verifying the proof.
The circuit was in fact underconstrained, and didn't check that the provided element was 32-bit long. An attacker could provide an index starting with the same 32 bits, but differing on the upper bits. The prover would then consider the index different, even though it refers to the same nullifier in the tree. Fixed by adding the constraint.
0xPARC StealthDrop: The protocol was using ECDSA signatures of a specific message with their private key as a nullifier. However, ECDSA signatures are non-deterministic, as the same private key and message can lead to many different signatures, all correct, because the process required to generate a random number (ephemeral key) while signing. The attacker was then able to generate many different nullifiers for the same inputs.
Two things worth knowing:
- The ephemeral nonce randomness is central to ECDSA, as using it twice to sign 2 different messages leaks the private key. This is why implementations randomize it.
- There is a deterministic variant (RFC 6979), where k is derived from the private key and message, making the resulting signature deterministic too for a same message, but this process requires the user to expose its private key too.
Field arithmetic and range issues
ZK circuits usually operate over scalar fields that are less than 256 bits (for example, SNARK circuits operate over ~254 bits for BN254). Any value bigger than the field modulus is then reduced modulo the field. This means that two different 256-bit values can be reduced to the same value, after modulo is applied. The issue appears when the nullifiers are stored as those 256-bit values, rather than their modulo value.
Here's a simple example with a modulus of 7: inside the circuit, 3, 10 and 17 are all the same element, since 10 mod 7 = 3 and 17 mod 7 = 3. Now imagine that the circuit works modulo 7 while the smart contract stores the integers as provided by the user: an attacker spends with nullifier 3, then spends again with 10. The circuit sees the same valid nullifier both times (which is 3), but the smart contract mapping has never seen 10 before and accepts it as a new nullifier.
a16z ZkDrops and EY Nightfall vulnerabilities both fall into this category: the nullifiers weren't range-checked against the scalar field and a same note could be consumed multiple times.
The fix was simple: add a range check ensuring that the provided nullifier is within the scalar field.
When auditing, any mismatch between how the circuit interprets a value and how the value is stored on-chain should be flagged as a potential source of double-spending.
Nullifier collisions
Even though it might look less problematic than the double-spending variant, this kind of vulnerability can have unintended consequences. In an early Zcash bug (before the protocol was live), an attacker could craft multiple distinct notes that shared a single nullifier and send them to the victim. The victim's wallet would successfully verify the ZK proofs, accept the transaction, and display the new balance incremented by these notes. The moment the victim spends one note, the shared nullifier is marked as consumed and the remaining notes become unspendable.
This vulnerability was known as the Faerie Gold attack.
When auditing, check both directions: one input gives one nullifier (determinism), and one nullifier maps to one input (collision resistance).
Front-running and griefing
When publishing a proof and a nullifier on-chain, the moment the transaction enters the mempool, it becomes visible to everyone. So what prevents an attacker from reusing those values and submitting the transaction themselves?
Let's take Tornado Cash as an example. If the proof doesn't bind all the public outputs (e.g. the recipient, the amount), a front-runner could copy the proof, re-submit it with a modified recipient, and consume the nullifier, stealing the user's deposit.
Tornado Cash avoids this with the correct design. At withdrawal, the user publishes a transaction with the proof, the nullifier, and the recipient. In a naive implementation, an attacker could simply swap in their own address as the recipient. Tornado Cash prevents this by making the recipient a public input to the proof itself:
verifier.verifyProof(
_proof,
[uint256(_root), uint256(_nullifierHash), uint256(_recipient),
uint256(_relayer), _fee, _refund]
)Changing any of these values invalidate the proof, making front-running attacks impossible.
When auditing, verify that every parameter with practical impact (recipient, amount, fee, target action) appears as a constrained public input. Any parameter left outside the proof is an invitation to front-run.
Domain separation
In some applications, like voting, it is important to include a scope identifier during the nullifier's derivation. Otherwise, the same nullifier can be valid in multiple contexts.
Example: A DAO runs its governance on a Semaphore-style membership tree, and derives each vote's nullifier as hash(identity_secret), forgetting the scope. Alice votes on proposal 1, revealing nul = hash(secret_alice). Two possible outcomes:
- Denial of Service: proposal 2 opens, Alice tries to vote, and produces the same
nul. If the program keeps one global nullifier set, her vote is rejected, preventing Alice, or any user, from voting on more than one proposal. - Linkability: suppose the program instead keeps one nullifier set per proposal (e.g. using a mapping such as
voted[proposal][nullifier]), so Alice can vote on both. Her nullifier is identical in each set. Anyone can now scan the sets, matchnulacross proposals, and build the full voting history of the anonymous member represented bynul: which proposals they showed up for, and with which vote if messages are public.
Include a context value (application ID, election ID, epoch, chain ID) in the hash. Semaphore's "external nullifier" is the reference design.
Conclusion
Nullifiers are an important primitive in private blockchain systems as they prevent double-spending issues while keeping the users anonymous. While its implementation and applications can vary widely, three properties always define a nullifier: deterministic, unique and hiding. If any of these properties break, the nullifier can become an attack vector against the protocol using it.
Determinism prevents repeated actions, uniqueness ensures that two actions can be differentiated, and hiding is what keeps the published value from uncovering the user. Each vulnerability class described previously fails one of these properties.
Nullifiers are just one piece of the ZK security surface, we'll keep breaking down others like this one. Follow us on X and LinkedIn to catch the next one.
If you're building a ZK protocol and want a second pair of eyes on your nullifier design (or anything else), click here or simply reach out at audits@adevarlabs.com.