Home Posts Designing Verifiable APIs with ZK Proofs [How-To] [2026]
Security Deep-Dive

Designing Verifiable APIs with ZK Proofs [How-To] [2026]

Designing Verifiable APIs with ZK Proofs [How-To] [2026]
Dillip Chowdary
Dillip Chowdary
Tech Entrepreneur & Innovator · May 12, 2026 · 9 min read

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 scope and a request digest.
  • Store and reject reused nullifier values 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 Set is 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

  1. Private: the caller identity secret and the path that proves membership in the eligible group.
  2. Public: the Merkle root, the proof points, the scope, the message digest, and the resulting nullifier.
  3. 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.

Watch out: Never let the prover choose business semantics. If the client can pick arbitrary scopes or unstructured messages, you have built a cryptographically valid but operationally weak API.

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 nullifier is 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.

Pro tip: Version your scopes like API routes. 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.mjs

Submit a proof payload:

curl -X POST http://localhost:3000/claims/verify \
  -H "content-type: application/json" \
  --data @request.json

Expected 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 ffjavascript should 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? +
A JWT usually carries claims in readable form and depends on the verifier trusting the issuer's signature. A zero-knowledge proof lets the verifier check that a claim contract was satisfied without learning the hidden inputs behind it. In practice, JWTs transport attributes, while ZK proofs transport evidence.
Do I need a custom circuit to build a verifiable API? +
Not always. If your API needs to verify group membership, one-time actions, or scoped eligibility, Semaphore is often enough. You need a custom circuit when the verifier must check a richer predicate, such as a bounded age range, balance threshold, or multi-field policy, without revealing the underlying values.
What should the server store after verifying a proof? +
Store the minimum needed for policy and replay protection: the 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? +
Bigger is better, but the minimum acceptable size depends on your threat model. Semaphore's own documentation explicitly warns that groups with 1 or 2 members cannot be considered anonymous. In production, treat small cohorts as a privacy risk even when the proof verifies correctly.

Get Engineering Deep-Dives in Your Inbox

Weekly breakdowns of architecture, security, and developer tooling — no fluff.

Found this useful? Share it.