Designing Verifiable APIs with ZK Proofs [How-To] [2026]
Bottom Line
A verifiable API should prove one bounded claim, bind it to a scope and request digest, and reject nullifier reuse. Those three checks let you exchange eligibility data without moving raw identity attributes.
Key Takeaways
- ›Bind every proof to one scope and one request digest.
- ›Reject reused nullifiers to stop replay across retries and replays.
- ›Accept only approved Merkle roots from your issuer or registry.
- ›Groups with 1 or 2 members are not meaningfully anonymous.
Most API security stacks still force a bad trade: either send raw user attributes to a verifier, or trust opaque bearer tokens that reveal more than the receiving service actually needs. Zero-knowledge proofs change that contract. Instead of asking for identity data, a verifiable API can ask for proof that a caller belongs to an allowed set and that the proof is bound to one request, one scope, and one moment in time.
- Bind every proof to a business
scopeand a request digest. - Store and reject reused
nullifiervalues to stop replay. - Treat Merkle roots as trust anchors, not as incidental metadata.
- Per Semaphore docs, groups with 1 or 2 members are not meaningfully anonymous.
Prerequisites
Before you start
- A recent Node.js LTS environment and
npm. - An issuer-controlled list of identity commitments that define who is eligible.
- A persistent store for used nullifiers. An in-memory
Setis fine for a demo only. - Semaphore packages:
@semaphore-protocol/identity,@semaphore-protocol/group, and@semaphore-protocol/proof. - Sanitized fixture data. If your sample payloads still contain customer details, run them through TechBytes' Data Masking Tool first.
Bottom Line
Do not design your API around “send me the proof and I will see what it means.” Design it around a fixed claim, a fixed scope, and a replay check. The proof should satisfy your contract, not define it.
Install the demo dependencies:
npm init -y
npm i express @semaphore-protocol/identity @semaphore-protocol/group @semaphore-protocol/proof
Step 1: Define the proof contract
The biggest design mistake in privacy-preserving APIs is proving something too broad. Start by defining one narrow claim. In this tutorial, the verifier wants only one fact: the caller is allowed to assert over-18 for a single merchant transaction, without sending date of birth or a user ID.
What should be public vs. private
- Private: the caller identity secret and the path that proves membership in the eligible group.
- Public: the Merkle root, the proof points, the
scope, themessagedigest, and the resultingnullifier. - Server-enforced: which group roots are trusted, which scopes are valid, and whether the nullifier was already used.
Use a digest as the proof message, not raw business data. That keeps the proof bound to the request while minimizing what crosses service boundaries.
import { createHash } from "node:crypto"
export function createClaimDigest({ claim, merchantId, requestId, issuedDay }) {
return createHash("sha256")
.update([claim, merchantId, requestId, issuedDay].join("|"))
.digest("hex")
}A minimal request shape looks like this:
{
"proof": { "...": "SemaphoreProof" },
"merchantId": "merchant-8472",
"requestId": "req-2026-05-12-001",
"issuedDay": "2026-05-12"
}This is the important mental model: the ZK proof proves group membership and binds that membership to the message and scope. Your API policy decides what that combination means.
Step 2: Generate the proof client-side
Semaphore is a good fit for this pattern because it already models anonymous group membership, scoped nullifiers, and proof verification. The client imports its identity, builds the eligible group, hashes the request context, and generates a proof bound to one scope.
import { Identity } from "@semaphore-protocol/identity"
import { Group } from "@semaphore-protocol/group"
import { generateProof } from "@semaphore-protocol/proof"
import { createClaimDigest } from "./claim-digest.mjs"
const identity = Identity.import(process.env.SEMAPHORE_IDENTITY)
const commitments = [
"12345678901234567890",
"23456789012345678901",
"34567890123456789012"
]
const group = new Group(commitments.map((c) => BigInt(c)))
const merchantId = "merchant-8472"
const requestId = "req-2026-05-12-001"
const issuedDay = "2026-05-12"
const scope = `age-check:${merchantId}`
const message = createClaimDigest({
claim: "over-18",
merchantId,
requestId,
issuedDay
})
const proof = await generateProof(identity, group, message, scope)
console.log({
scope,
message,
nullifier: proof.nullifier,
merkleTreeRoot: proof.merkleTreeRoot
})This gives you two strong properties immediately:
- The proof is unlinkable to a real identity if the anonymity set is large enough.
- The
nullifieris deterministic for that identity and scope, so the verifier can reject duplicates without learning who the caller is.
Semaphore's docs also note an operational nuance many teams miss: groups with 1 or 2 members cannot be considered anonymous. That matters in testing. A proof that is cryptographically valid can still be privacy-weak if the set is tiny.
age-check:v1:merchant-8472 is much easier to rotate safely than a free-form topic string that drifts across services.Step 3: Verify at the API boundary
On the server, cryptographic verification is only the first gate. A production verifier should also check that the proof is bound to the expected request digest, that the Merkle root is one you trust, and that the nullifier has not already been consumed.
import express from "express"
import { verifyProof } from "@semaphore-protocol/proof"
import { createClaimDigest } from "./claim-digest.mjs"
const app = express()
app.use(express.json())
const allowedRoots = new Set([process.env.ALLOWED_GROUP_ROOT])
const usedNullifiers = new Set()
app.post("/claims/verify", async (req, res) => {
const { proof, merchantId, requestId, issuedDay } = req.body
const scope = `age-check:${merchantId}`
const expectedMessage = createClaimDigest({
claim: "over-18",
merchantId,
requestId,
issuedDay
})
const cryptographicallyValid = await verifyProof(proof)
if (!cryptographicallyValid) {
return res.status(401).json({ ok: false, error: "invalid-proof" })
}
if (proof.scope !== scope || proof.message !== expectedMessage) {
return res.status(400).json({ ok: false, error: "binding-mismatch" })
}
if (!allowedRoots.has(proof.merkleTreeRoot)) {
return res.status(409).json({ ok: false, error: "unknown-group-root" })
}
if (usedNullifiers.has(proof.nullifier)) {
return res.status(409).json({ ok: false, error: "replay-detected" })
}
usedNullifiers.add(proof.nullifier)
return res.json({
ok: true,
decision: "allow",
anonymousSubject: `anon:${proof.nullifier.slice(0, 12)}`,
verifiedScope: proof.scope
})
})
app.listen(3000, () => {
console.log("Verifier listening on http://localhost:3000")
})That endpoint is the core of a verifiable API design. It never asks for DOB, passport number, email, or wallet history. It only accepts a proof that satisfies a narrow policy contract.
For real deployments, replace the in-memory nullifier store with a database constraint and distribute trusted Merkle roots from your issuer over a signed control channel. The verifier should treat those roots exactly like any other trust anchor.
Verification and expected output
Run the verifier
node server.mjsSubmit a proof payload:
curl -X POST http://localhost:3000/claims/verify \
-H "content-type: application/json" \
--data @request.jsonExpected output
{
"ok": true,
"decision": "allow",
"anonymousSubject": "anon:198233918233",
"verifiedScope": "age-check:merchant-8472"
}If you submit the exact same proof again, the verifier should reject it:
{
"ok": false,
"error": "replay-detected"
}Troubleshooting top 3
- binding-mismatch: your client and server built the digest from different fields or in a different order. Keep one shared digest helper and do not hand-roll JSON serialization in two places.
- unknown-group-root: your issuer rotated the group snapshot, but the verifier did not refresh its trusted root set. Treat root distribution like key rotation, not like casual config drift.
- process hangs in tests: Semaphore documents that Node.js tests using the bn128 curve via
ffjavascriptshould terminate the curve handle when finished, or Node may keep open handles.
If you want to polish examples before publishing them internally, TechBytes' Code Formatter is a quick way to normalize snippets without changing the logic.
What's next
Once this pattern is stable, you can extend it in a few practical directions:
- Move from in-memory nullifier tracking to a durable table with a uniqueness guarantee.
- Add proof expiry by including a short-lived time bucket in the digest or scope.
- Support root rotation by serving a signed registry of currently accepted Merkle roots.
- When group membership is not expressive enough, build a custom circuit with Circom 2 and verify the resulting proof artifacts with snarkjs.
The key architectural shift is simple: ask for proofs of eligibility, not copies of personal data. Once your API boundary is built around scopes, digests, trusted roots, and nullifiers, privacy stops being a bolted-on compliance story and becomes a property of the interface itself.
Frequently Asked Questions
How is a zero-knowledge proof different from a JWT in an API flow? +
Do I need a custom circuit to build a verifiable API? +
What should the server store after verifying a proof? +
nullifier, the accepted scope, the trusted group root used for the decision, and your own request identifier. Avoid storing raw identity attributes, and only retain the full proof if you truly need it for audit or dispute handling.How large should the anonymity set be for a privacy-preserving API? +
Get Engineering Deep-Dives in Your Inbox
Weekly breakdowns of architecture, security, and developer tooling — no fluff.