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, arkworks compiled with wasm-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.

What verification actually computes The proof and public inputs are decoded into field elements and curve points, a small number of multi-scalar multiplications and pairings are computed, and the result is a single boolean. All of the cost is in the middle layer. proof + public inputs a few hundred bytes compiled verifier multi-scalar multiplication pairing check true or false one bit, and it means nothing — nothing, that is, unless you also check that the public inputs are the ones your application expects. A proof is a statement about specific values. Verifying a proof against attacker-supplied public inputs proves only that the attacker can make proofs about their own values.

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.

Two gates, not one A valid proof passes the cryptographic check, but acceptance also requires every public input to match a value the application chose. Both gates must pass, and only the first is provided by the verifier. proof verifies cryptographic gate public inputs match application gate — yours to write accept and only then skipped → attacker proves their own statement The second gate is application code, gets no help from the library, and is the one that is forgotten.

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.

Verification is small; setup is not The verification itself is milliseconds. Fetching the verifying key and preparing the curve arithmetic dominate, and both are one-off costs that caching removes. fetch verifying key 640 ms on a cold load cached afterwards prepare parameters 290 ms once per session verify proof 34 ms the only part that repeats for every proof Because verification is cheap and setup is not, batch: prepare once and verify many proofs against it. SIMD roughly halves the field arithmetic here, which makes it one of the clearest wins available.

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.

← Back to Cryptography & Untrusted Code