Circom is the most popular language for writing ZK circuits. It compiles arithmetic constraints into R1CS (Rank-1 Constraint System) representations that can be consumed by proof systems like Groth16 and PLONK. This hands-on tutorial takes you from installation to your first working proof, then shows you how circuits like these power production-grade authentication systems.
Why Circom Matters
Zero-knowledge proofs allow one party to prove a statement is true without revealing the underlying data. The challenge has always been expressing those statements as arithmetic circuits that a proof system can evaluate. Circom solves this by providing a domain-specific language (DSL) that compiles to R1CS constraints, bridging the gap between human-readable logic and the finite-field arithmetic that zk-SNARKs require.
In production systems, ZK circuits handle everything from identity verification to credential checks. H33 uses zero-knowledge proofs as the verification layer in a pipeline that processes 2,172,518 authentications per second at ~42 microseconds per auth. The ZKP lookup stage itself completes in just 0.085 microseconds via an in-process DashMap cache. Circom is where many developers first learn the constraint-based thinking that underlies these systems.
Installation
# Install Rust (if needed)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Install Circom
git clone https://github.com/iden3/circom.git
cd circom
cargo build --release
cargo install --path circom
# Install snarkjs
npm install -g snarkjs
Your First Circuit
Create a simple circuit that proves knowledge of factors:
// multiplier.circom
pragma circom 2.0.0;
template Multiplier() {
// Private inputs (witness)
signal private input a;
signal private input b;
// Public output
signal output c;
// Constraint: a * b must equal c
c <== a * b;
}
component main = Multiplier();
Understanding the Constraint Model
Every Circom circuit compiles down to a set of R1CS constraints of the form A * B = C, where A, B, and C are linear combinations of signals. The <== operator simultaneously assigns a value and creates a constraint. This is critical: if you only assign with <-- without a separate constraint via ===, the prover can supply any value and the proof will still verify. Unconstrained signals are the single most common source of ZK circuit vulnerabilities.
Never use the assignment operator <-- without a corresponding === constraint. The combined <== operator handles both assignment and constraint in one step. If you must separate them (for non-quadratic expressions), always verify that every signal path is fully constrained.
Compiling the Circuit
# Compile to R1CS
circom multiplier.circom --r1cs --wasm --sym
# This generates:
# - multiplier.r1cs (constraint system)
# - multiplier_js/ (WASM for witness generation)
# - multiplier.sym (symbol file for debugging)
The --r1cs flag produces the constraint system, --wasm generates a WebAssembly witness calculator, and --sym emits a symbol table that maps signal names to their wire indices. For larger circuits, add --O1 to enable constraint optimization, which can significantly reduce the number of constraints and speed up both proving and verification.
Trusted Setup
# Powers of tau ceremony (can reuse for circuits up to 2^n constraints)
snarkjs powersoftau new bn128 12 pot12_0000.ptau
snarkjs powersoftau contribute pot12_0000.ptau pot12_0001.ptau
snarkjs powersoftau prepare phase2 pot12_0001.ptau pot12_final.ptau
# Circuit-specific setup
snarkjs groth16 setup multiplier.r1cs pot12_final.ptau multiplier_0000.zkey
snarkjs zkey contribute multiplier_0000.zkey multiplier_final.zkey
snarkjs zkey export verificationkey multiplier_final.zkey verification_key.json
The trusted setup is a two-phase process. Phase 1 (Powers of Tau) is universal and can be reused across any circuit up to a certain size. Phase 2 is circuit-specific. In production deployments, ceremonies involve multiple independent contributors so that the toxic waste (the random values used during setup) is destroyed as long as at least one participant is honest. For applications where setup trust is unacceptable, consider PLONK with a universal SRS or STARKs, which require no trusted setup at all.
Generating a Proof
// Create input file: input.json
{ "a": 3, "b": 5 }
// Generate witness
node multiplier_js/generate_witness.js multiplier_js/multiplier.wasm input.json witness.wtns
// Generate proof
snarkjs groth16 prove multiplier_final.zkey witness.wtns proof.json public.json
Verifying the Proof
# Verify
snarkjs groth16 verify verification_key.json public.json proof.json
# Output: [INFO] OK!
What Was Proven
The prover demonstrated knowledge of a=3 and b=5 such that 3x5=15.
The verifier sees only that c=15 and that the proof is valid.
The values of a and b remain hidden.
More Complex Example: Age Verification
Real-world circuits rarely involve a single multiplication. The age verification example below demonstrates how to compose library components from circomlib to build practical privacy-preserving checks:
pragma circom 2.0.0;
include "circomlib/comparators.circom";
template AgeCheck() {
signal private input birthYear;
signal input currentYear;
signal input minimumAge;
signal output isOldEnough;
component gte = GreaterEqThan(16);
gte.in[0] <== currentYear - birthYear;
gte.in[1] <== minimumAge;
isOldEnough <== gte.out;
}
component main = AgeCheck();
The GreaterEqThan(16) comparator uses 16 bits to represent the comparison range, meaning it handles values up to 65,535. Choosing the right bit-width is a common design decision: too few bits and your circuit silently wraps around, too many and you add unnecessary constraints. Each bit of comparison range costs roughly one constraint, so a 16-bit comparator adds about 16 constraints to your circuit.
Constraint Optimization Strategies
As circuits grow in complexity, constraint count directly impacts proving time and memory usage. A circuit with 100,000 constraints might take several seconds to prove on consumer hardware. Here are the techniques that matter most:
- Minimize comparisons. Range checks and comparators are expensive because they decompose values into individual bits. A single 254-bit range check adds over 250 constraints.
- Batch operations. If you need to verify multiple values against the same condition, restructure the circuit to share intermediate signals rather than duplicating subcircuits.
- Use lookup tables. For complex non-linear functions (like SHA-256), lookup-based approaches can dramatically reduce constraint count compared to direct arithmetic implementations.
- Leverage
circomlib. The standard library contains heavily optimized templates for hashing (Poseidon, MiMC), comparisons, and binary operations. These are audited and constraint-efficient.
| Operation | Approx. Constraints | Notes |
|---|---|---|
| Multiplication | 1 | Single R1CS constraint |
| Addition | 0 | Free (linear combination) |
| Bit decomposition (n bits) | n + 1 | Range check included |
| Poseidon hash (2 inputs) | ~240 | ZK-friendly, preferred |
| SHA-256 | ~28,000 | Expensive but widely compatible |
| ECDSA verify | ~500,000 | Consider EdDSA (~6,000) instead |
From Circom to Production ZKP
Circom circuits give you a mental model for how zero-knowledge systems operate: private inputs flow through constraints, producing a proof that reveals nothing beyond the stated public outputs. Production systems like H33 take this principle further by combining ZKP verification with BFV fully homomorphic encryption for biometric matching and Dilithium post-quantum signatures for attestation, all within a single API call.
The same constraint-based reasoning you learn writing Circom circuits applies directly to understanding how H33 batches 32 users into a single BFV ciphertext and verifies each authentication in ~42 microseconds with post-quantum security at every stage.
Best Practices
- Use circomlib for common operations. Do not re-implement hashing, comparisons, or EdDSA verification from scratch. The standard library is audited and optimized.
- Minimize constraints for performance. Every constraint increases proving time linearly. Profile your circuit with
circom --r1csand check the constraint count before deployment. - Test with edge-case inputs. Zero values, maximum field values, and boundary conditions are where unconstrained signals cause exploitable bugs.
- Consider constraint count vs. proving time trade-offs. A circuit with 10,000 constraints proves in under a second on modern hardware. At 1,000,000 constraints, expect 10-30 seconds depending on the proof system.
- Audit for under-constrained signals. Tools like
circomspectcan automatically detect signals that lack constraints, which is the most critical vulnerability class in ZK circuits.
Circom is the gateway to practical ZK development. Start simple, understand the constraint model, then build toward more complex privacy-preserving proofs. The thinking you develop here -- expressing computation as arithmetic constraints over finite fields -- is the same foundation that powers the ZKP verification layer in production systems processing millions of authentications per second.
Ready to Go Quantum-Secure?
Start protecting your users with post-quantum authentication today. 1,000 free auths, no credit card required.
Get Free API Key →