ZK App Security Checklist

Why ZK security is different

Smart contract bugs are costly. ZK circuit bugs are catastrophic.

A bug in a smart contract lets an attacker drain a pool or bypass a check. A bug in a ZK circuit lets an attacker forge a proof — to fake a transaction, mint tokens from nothing, or withdraw funds they do not have, all while producing a proof that passes cryptographic verification.

A 2024 systematic analysis of SNARK vulnerabilities found that approximately 96% of documented bugs in production ZK systems were caused by under-constrained circuits. A zkSync Era circuit bug disclosed in September 2023 would have allowed a malicious prover to forge proofs for invalidly executed blocks that L1 verifier contracts would have accepted. The vulnerability was fixed before exploitation and awarded a $50,000 bounty.

These are not edge cases. They are the expected failure mode of ZK circuit development without a disciplined review process.

This checklist covers every category of ZK security issue. Work through it before any circuit goes to production.

Category 1 — Constraint Completeness

This is the most critical category. 96% of bugs live here.

Every signal that affects the circuit output must be constrained.

In Circom, this means every use of <-- must have a corresponding === constraint. Assignment without constraint is unconstrained degrees of freedom. A malicious prover can set the value to anything while still satisfying the rest of the circuit.

```circom // VULNERABLE signal intermediate; intermediate <-- a * b; // assigned, not constrained out <== intermediate + c; // out depends on unconstrained signal

// CORRECT signal intermediate; intermediate <== a * b; // assigned AND constrained out <== intermediate + c; ```

In Noir, the compiler handles constraint generation automatically from typed operations. But logic errors remain possible. An assert that is never reached due to control flow, or an assertion that checks the wrong relationship, produces a circuit that compiles and proves correctly but verifies incorrect claims.

Checklist items:

Review every <-- in Circom circuits and confirm the corresponding === exists. There are no exceptions.

For every intermediate computation, confirm that the relationship between the intermediate value and its inputs is explicitly constrained, not just computed.

Run differential testing: generate proofs with two different witness values for the same public input. If both verify, you have an under-constrained signal.

Use static analysis tools. Picus detects under-constrained Circom circuits using symbolic execution. CIVER applies formal verification to circuit components. For Noir, run nargo test and write tests specifically designed to pass invalid witnesses through the circuit.

Category 2 — Field Arithmetic Edge Cases

ZK circuits operate over a finite field. The field has a prime modulus. Arithmetic wraps around at that modulus. This creates a class of bugs with no equivalent in regular programming.

Underflow is not negative.

In standard integer arithmetic, 0 - 1 = -1. In the BN254 scalar field used by Barretenberg, 0 - 1 equals the field prime minus one — a very large number. A balance check that does not range-constrain inputs can be bypassed by triggering this wraparound.

```rust // VULNERABLE in Noir — no range check fn main(balance: Field, withdrawal: Field) { assert(balance - withdrawal >= 0); // Field has no concept of >= 0 }

// CORRECT — use typed integers which carry range constraints fn main(balance: u64, withdrawal: u64) { assert(balance >= withdrawal); // u64 type prevents wraparound at the type level } ```

Non-uniqueness of field representations.

A value can have multiple valid bit decompositions in a field. If your circuit decomposes a value into bits and does not check that the decomposition is canonical, a prover can supply a non-canonical representation that satisfies the bit constraints but represents a different value.

Checklist items:

Never use Field for values with semantic range constraints. Use typed integers (u8, u32, u64) instead. The type carries the range constraint.

For any subtraction between field elements that could underflow, add an explicit range check proving the minuend is at least as large as the subtrahend.

For bit decompositions, explicitly constrain that each bit is 0 or 1 and that the sum of the decomposition equals the original value.

Category 3 — Public Input Validation

Public inputs are the anchor of the proof. The proof says: I know a witness such that these public inputs and this circuit produce a valid statement. If your application does not validate public inputs before accepting a proof, an attacker can submit a valid proof for a different statement.

The verifier contract must check public inputs match expected values.

A valid proof that f(secret, malicious_root) = true is not the same as a valid proof that f(secret, correct_root) = true. Both proofs may pass cryptographic verification. Your contract must check which root was used.

```solidity // VULNERABLE function claim(bytes calldata proof, bytes32[] calldata publicInputs) external { require(verifier.verify(proof, publicInputs), "Invalid proof"); // NEVER checks what publicInputs[0] actually is _transfer(msg.sender, amount); }

// CORRECT function claim(bytes calldata proof, bytes32[] calldata publicInputs) external { require(verifier.verify(proof, publicInputs), "Invalid proof"); require(publicInputs[0] == bytes32(registeredRoot), "Wrong root"); require(!usedNullifiers[publicInputs[1]], "Already claimed"); _transfer(msg.sender, amount); } ```

Checklist items:

For every public input, write an explicit check in the verifier contract confirming it matches the expected value or state.

Nullifiers must be stored and checked on-chain. The contract must reject any proof whose nullifier has been previously recorded.

Public inputs are ordered. Confirm the order in your Solidity verifier matches the order in your Noir or Circom circuit. A mismatch passes cryptographic verification but applies the wrong semantics.

Category 4 — Trusted Setup Handling

For Groth16, the trusted setup produces a circuit-specific proving key. For universal setups like PLONK and UltraHonk, a shared SRS is used. For both, the verification key must match the circuit.

Verification key mismatch allows proof forgery.

If your verifier contract uses a stale verification key from a previous circuit version, a prover can generate proofs against the old circuit that the contract accepts, while your application assumes the new circuit logic is being enforced.

Checklist items:

When you update a circuit, generate a new verification key and redeploy or update the verifier contract before accepting any new proofs.

For Groth16 circuits, ensure the trusted setup was performed for the exact circuit version in production. A different constraint count or different circuit topology requires a new ceremony.

For Barretenberg, the --oracle_hash keccak flag is required for EVM-compatible verification keys. A verification key generated without this flag cannot be used with the Solidity verifier. Confirm the flag is present in your build scripts.

Store the circuit commit hash alongside the verification key in your deployment. This creates an auditable link between the deployed verifier and the circuit that generated it.

Category 5 — Proof Delegation and Prover Trust

When proof generation is delegated to a third-party service, the prover has access to all inputs including private ones. This creates two risks: witness leakage and malicious proof substitution.

Witness leakage: A delegated prover receives the witness. If the witness contains sensitive data and the prover is compromised, that data is exposed.

Malicious proof substitution: A delegated prover could generate a proof for a different statement and return it. If your application does not verify that the proof corresponds to the intended computation, this substitution goes undetected.

Checklist items:

For privacy-critical applications, proof generation must happen client-side. Private inputs must never leave the user's device. If server-side proving is required, the architecture should be designed so the server learns only what the public inputs reveal.

When using proving services like Succinct, Sindri, or custom proving infrastructure, verify the returned proof against your verification key locally before submitting it on-chain. Never submit an unverified proof from an external service.

If using zkVerify for verification, confirm that the registered verification key hash matches your circuit. A proof submitted against the wrong vkHash fails verification but could indicate a circuit mismatch that requires investigation.

Category 6 — Completeness Verification

Under-constrained bugs get most of the attention. Over-constrained bugs are less common but also dangerous. An over-constrained circuit rejects valid witnesses, causing honest provers to fail. In a financial protocol, this means legitimate users cannot execute transactions.

Over-constrained circuits arise from extra constraints that legitimate solutions cannot satisfy — usually from overly specific range checks, incorrect hash function assumptions, or bit length restrictions that do not match the actual value space.

Checklist items:

Write tests that generate proofs for valid witnesses at the boundary of every range. If a circuit proves age verification for ages 18 to 120, test with age 18, age 120, and ages near both boundaries.

Confirm bit length constraints match the actual range of values your application will encounter. Restricting a field element to 32 bits when the application can produce 64-bit values causes completeness failures that look like random prover crashes.

For circuits that process arrays or trees, test at minimum size, maximum size, and edge cases where indices are 0 or at the length boundary.

Category 7 — Integration Security

The circuit can be correct and the verifier contract can be correct, but their integration can still be broken.

Replay attacks: A valid proof accepted once can be replayed. Nullifiers prevent this for anonymity patterns. For non-anonymous use cases, include a nonce or block-specific parameter in the circuit inputs that the contract checks.

Front-running: Proof submission transactions are visible in the mempool before inclusion. If the proof contains information that can be extracted and resubmitted by a front-runner, the protocol is vulnerable. This is mitigated by binding the proof to the submitter's address as a public input.

Proof malleability: Some proof systems allow transforming a valid proof into another valid proof for the same statement. If your protocol uses proof bytes as identifiers or stores them for deduplication, malleability can bypass deduplication. Use the statement hash or nullifier as the identifier, not the raw proof bytes.

Checklist items:

Confirm every proof can only be used once. If nullifiers are not part of your circuit design, add a nonce or timestamp that the contract enforces is unused.

If front-running is a concern, include msg.sender as a public input in the circuit. This binds the proof to a specific submitter and makes it worthless to a front-runner.

Never use proof bytes as a primary key or deduplication identifier. Use the nullifier or a hash of the public inputs.

The audit checklist — summary

Before deployment, confirm each of these:

``` CONSTRAINT COMPLETENESS [ ] Every <-- has a corresponding === constraint [ ] Differential testing shows no two witnesses verify for the same public input [ ] Static analysis (Picus, circom-secq) has been run

FIELD ARITHMETIC [ ] No raw Field subtraction without range checks [ ] All semantic integer values use typed integers [ ] Bit decompositions are canonicity-checked

PUBLIC INPUTS [ ] Verifier contract checks every public input value [ ] Nullifiers are stored and checked on-chain [ ] Public input ordering matches between circuit and verifier contract

TRUSTED SETUP [ ] Verification key matches current circuit version [ ] Groth16: ceremony was performed for this circuit [ ] Barretenberg: --oracle_hash keccak used for EVM

PROOF DELEGATION [ ] Private inputs do not leave user device for privacy-critical applications [ ] External proofs are locally verified before on-chain submission

COMPLETENESS [ ] Valid witnesses at range boundaries produce successful proofs [ ] Array and tree circuits tested at min and max sizes

INTEGRATION [ ] Proofs cannot be replayed [ ] Front-running mitigated where applicable [ ] Proof bytes not used as identifiers ```

Getting an audit

Circuit audits require specialized expertise that is different from standard smart contract auditing. An auditor who is expert in Solidity is not automatically qualified to audit a Circom or Noir circuit.

Firms with demonstrated ZK circuit audit experience include Trail of Bits, Zellic, zkSecurity, ABDK, and Veridise. zkVerify itself was audited by Trail of Bits in February 2025 and SRLabs in September 2025.

The audit should cover both the circuit and the integration layer. A circuit that is formally sound can still be broken by a verifier contract that does not check the right public inputs.

Answer the quiz correctly to continue →

Quiz · Multiple Choice1 / 3

According to the ZK security checklist, what is the root cause behind approximately 96% of documented vulnerabilities in production ZK systems?