Verifying Zero-Knowledge Proofs in the Browser
This guide answers one task: verify a zero-knowledge proof in a browser tab, using a compiled verifier, with a realistic view of the payload it costs and where the boundary between client and server should sit.
Prerequisites
- [ ] A proving system with a WebAssembly verifier —
snarkjs,arkworkscompiled withwasm-pack, or a Halo2 verifier built for the web. - [ ] A verification key and a proof in the format your system emits.
- [ ] A worker, because field arithmetic is CPU-bound and not instant.
- [ ] Clarity about what the proof asserts. A valid proof of the wrong statement is worthless.
Why the platform cannot do this
Proof verification is arithmetic over large prime fields and, for pairing-based systems, bilinear pairings
over elliptic curves such as BN254 or BLS12-381. SubtleCrypto implements none of that: it offers a fixed
set of NIST curves for signatures and key agreement, with no primitive operations exposed.
Implementing it in JavaScript means big-integer arithmetic with BigInt, which is correct but slow —
typically ten to thirty times slower than compiled code for modular multiplication, because every
operation allocates. A compiled module using 64-bit limbs and Montgomery arithmetic is the only
practical option, and it is a clear-cut case where WebAssembly is the enabling technology rather than an
optimisation.
Load the verification key once
The verification key is a fixed artifact derived from the circuit. It changes only when the circuit does, so it should be fetched once, cached aggressively, and pinned by hash — a substituted verification key makes every proof verify, which is the single most damaging failure available in this area.
const VK_URL = '/zk/verifier-key.a91c3f.json'; // hashed filename, immutable cache
let vkPromise = null;
function loadVerificationKey() {
vkPromise ??= fetch(VK_URL).then(async (res) => {
const bytes = new Uint8Array(await res.arrayBuffer());
if (await sha256Hex(bytes) !== EXPECTED_VK_HASH) throw new Error('verification key mismatch');
return JSON.parse(new TextDecoder().decode(bytes));
});
return vkPromise;
}
The hash check is not paranoia. The verification key is the root of trust for everything this feature asserts; if an attacker can replace it, they can produce proofs that verify for statements you never intended. Pin it at build time and check it at load time.
Verify in a worker
Verification is milliseconds to a few hundred milliseconds depending on the system and circuit size. That is enough to drop frames, so it belongs off the main thread — and the module should be instantiated once and reused.
// zk-worker.js
import * as snarkjs from 'snarkjs';
const vk = await loadVerificationKey();
self.onmessage = async ({ data: { id, proof, publicSignals } }) => {
try {
const ok = await snarkjs.groth16.verify(vk, publicSignals, proof);
self.postMessage({ id, ok });
} catch (e) {
self.postMessage({ id, ok: false, error: String(e) });
}
};
Treat a thrown error as a failed verification rather than an infrastructure problem. Malformed proofs
from an attacker will throw on decoding rather than return false, and code that distinguishes them
tends to end up treating an exception as “could not verify, proceed anyway”.
Check the public inputs, not just the proof
The most common security mistake in this area is verifying the proof and ignoring what it is a proof of. A verifier answers one question: is this a valid proof that the prover knew a witness satisfying the circuit for these public inputs. If the public inputs came from the same untrusted source as the proof, the answer tells you nothing about your application’s state.
const { ok } = await verify(proof, publicSignals);
if (!ok) return reject('invalid proof');
// bind the proof to the context your application cares about
if (publicSignals[0] !== expectedMerkleRoot) return reject('wrong root');
if (publicSignals[1] !== sessionNonce) return reject('replayed proof');
if (publicSignals[2] !== BigInt(userId)) return reject('proof for another user');
Every public signal must be compared against a value your application chose. A nonce prevents replay, a root binds the proof to a state you trust, an identifier binds it to the right subject. Skipping any of them turns a cryptographic guarantee into decoration.
Expected output
A verification run logs the outcome, the time and — usefully during development — the public signals it checked:
vk loaded : 812 kB, hash ok
proof size : 256 bytes
public signals : [ root=0x8f3a…, nonce=0x51c2…, user=42 ]
verify : true in 41 ms
bindings : root ok, nonce ok, user ok → accepted
A verification that returns true in under a millisecond is suspicious: it usually means the library
short-circuited on a malformed input rather than performing the pairing. Time it and be suspicious of
numbers that are too good.
Keeping the circuit and the verifier in step
A verifier is compiled against a specific circuit. When the circuit changes — a constraint added, a public input reordered — the verification key changes, and every proof produced under the old key stops verifying. That is correct behaviour and a deployment hazard, because the client and the prover are updated at different times.
Version the whole set together. Emit a circuit identifier alongside the proof, have the client select the matching verification key by that identifier, and keep the previous key available for a transition window. A client that receives a proof for an unknown circuit should reject it clearly rather than attempting verification against whatever key it happens to hold — the failure would otherwise look like an invalid proof and send everyone debugging the wrong thing.
const VKS = {
'transfer-v3': { url: '/zk/vk-transfer-v3.a91c3f.json', hash: '…' },
'transfer-v4': { url: '/zk/vk-transfer-v4.7b2e10.json', hash: '…' },
};
const entry = VKS[proofEnvelope.circuit];
if (!entry) return reject(`unknown circuit ${proofEnvelope.circuit}`);
The same identifier belongs in your logs. When a support report says proofs stopped verifying, the first question is which circuit version each side was using, and having it recorded turns a long investigation into a one-line answer.
Payload budgets
This is where browser deployment gets awkward. The numbers vary by system, and they are large:
| Artifact | Typical size | Notes |
|---|---|---|
| Groth16 verifier module | 300–900 kB | Fetch lazily; cache hard |
| Verification key | 1 kB – 2 MB | Grows with the number of public inputs |
| Proof | 200 bytes – 2 kB | Negligible |
| Proving key | 10 MB – multiple GB | Do not ship this to a browser |
Verifying in a browser is reasonable. Proving in a browser rarely is: the proving key alone can exceed what a tab will hold, and proving time is seconds to minutes even with threads. If a design calls for client-side proving, budget for it explicitly and prototype it before the architecture depends on it.
Threads help, when you can have them
Field arithmetic parallelises well, and both snarkjs and arkworks-based verifiers use threads when
SharedArrayBuffer is available. Verification typically improves by 2–3× on four threads, which for a
40 ms verification is not dramatic — but for a large circuit, or for proving, it is the difference
between usable and not.
That requires cross-origin isolation, with all the site-wide implications described in
multi-threaded inference with Wasm threads.
Check crossOriginIsolated and log it; a deploy that silently drops the headers will halve your
verification throughput with no other symptom.
Gotchas
- Verification key not pinned. The root of trust for the whole feature. Hash it and check it.
- Public inputs unchecked. The proof is valid and irrelevant.
- No nonce. Proofs replay indefinitely. Bind each to something single-use.
- Exception treated as an infrastructure error. Malformed proofs throw. Treat a throw as a rejection.
- Proving attempted client-side. Check the proving key size before designing around it.
- Curve mismatch between prover and verifier. BN254 and BLS12-381 artifacts are not interchangeable, and the error usually surfaces as a decode failure rather than an explanation.
Performance note
A Groth16 verification over BN254 with a handful of public inputs takes roughly 25–60 ms single-threaded
in a compiled verifier, against 400–900 ms for an equivalent BigInt implementation in JavaScript. The
verifier module is around 600 kB compressed and instantiates in 15–25 ms once cached. Verification cost
is nearly independent of circuit size, which is the property that makes client-side verification viable
at all — proving cost is not.
Frequently Asked Questions
Can I prove in the browser at all? For small circuits, yes, with threads and patience — a few seconds for something modest. For anything resembling a production circuit, the proving key size alone usually rules it out. Prove on a server or in a dedicated application.
Which proving system is friendliest to browser verification? Groth16 has the smallest proofs and fastest verification but requires a per-circuit trusted setup. PLONK and Halo2 have larger proofs and slower verification with a universal or absent setup. For a browser verifier, the tradeoff usually favours Groth16.
Does verifying client-side gain me anything over verifying on the server? It lets the client check something without trusting your server, which matters for decentralised applications and for transparency claims. If your server is already trusted by the client, verifying there is simpler and cheaper.
Related
- Sandboxing untrusted code with Wasm — containment for code you did not write.
- Loading large model weights into linear memory — the same streaming problem for large keys.
- Caching compiled Wasm modules in IndexedDB — making the verifier cheap on repeat visits.
← Back to Cryptography & Untrusted Code