Browser and WASM

Light is not ZK. There is no WASM prover.

Two sentences that clear up the most common confusion about AffixIO on the web. State HMAC Light proofs are not zero knowledge, and nothing here proves through WebAssembly. There is a browser and edge package, @affix-io/sdk-web, built on Web Crypto and Fetch, and it changes where code runs rather than where secrets live. A web application still verifies a carrier by talking to a server it controls, and that server is the only place the API key ever lives.

Last checked against the published packages and the running API on .

Raspberry Pi 4 Model B photographed from above, showing the board layout and connectors
The interesting computer is usually the one at the edge, not the one in the tab. Photograph of a Raspberry Pi 4 by the Raspberry Pi Foundation, CC BY-SA 4.0, cropped and desaturated.

Available, and not

Three packages, two runtimes, no WebAssembly. @affix-io/sdk and @affix-io/sdk-light declare engines.node >= 18, ship as Node ESM and have no browser export field. @affix-io/sdk-web is the browser and edge client: Web Crypto and Fetch only, no Node APIs, distributed as source by AffixIO rather than published on npm. There is no WASM build for either proving path. That is the packaging statement. Everything else on this page follows from it.

Available today

  • @affix-io/sdk 1.1.1 on Node: UltraHonk zero-knowledge prove, ML-DSA-65 sync, Merkle audit.
  • @affix-io/sdk-light 1.1.3 on Node: HMAC-SHA256 Light prove, local verifyLocal, same sync and audit surfaces.
  • @affix-io/sdk-web 0.1.0 as source: AffixLightWeb for local Light prove and verify on Web Crypto, AffixWebClient for REST, webhook signature verification, presentment token packing. Runs in modern browsers, Cloudflare Workers, Deno and any Fetch plus SubtleCrypto runtime.
  • The HTTP API at api.affix-io.com, called from any server you control.
  • Public endpoints that need no key: the Merkle root and the ML-DSA-65 public key.
  • Carrier scanning in the browser with any QR library, so long as the verify call stays on your backend.

Not available

  • An npm listing for @affix-io/sdk-web. It ships as source from AffixIO.
  • A WASM UltraHonk prover. Zero-knowledge proving stays on Node.js.
  • An AffixIO API key that is safe to put in client JavaScript. There is no such thing.
  • A claim that Light proofs are zero knowledge. They are not.
  • Direct credentialed browser calls to the API from arbitrary origins. CORS is restricted to known AffixIO origins.

Light is not ZK

This is the distinction that most often gets blurred, so it gets its own section.

Two proving paths, two different claims
@affix-io/sdk@affix-io/sdk-light
Proof typeUltraHonk zero-knowledge proof over a Noir circuitHMAC-SHA256 state proof
Zero knowledgeYes. The verifier learns the boolean outcome, not the witness.No. The verifier holds a shared secret and recomputes the MAC.
Typical costSeconds per proof, depending on the circuit and the hostMilliseconds on Node's built-in crypto
Local verify without a network round tripThrough the SDK's verify path against AffixIO sync stateverifyLocal, designed for this
Where it runsNode.js 18+Node.js 18+, or any Web Crypto runtime through @affix-io/sdk-web
What AffixIO signs on syncThe outcome, with ML-DSA-65The outcome, with ML-DSA-65

Light exists because some deployments need to produce and check many proofs per second and can live with a shared-secret model. That is a real and useful engineering trade-off. Calling it zero knowledge would be a different claim, and a false one. If a reviewer asks which path you are on, point them at the package name and at this table.

State HMAC, said carefully

A Light proof is a message authentication code over a structured payload that includes the circuit outcome and the state the prover claims. Anyone holding the shared secret can recompute it and decide whether the claim matches. Anyone without the secret cannot forge one. That is integrity and authenticity under a shared secret, which is not the same property as a zero-knowledge argument.

How a web application verifies a carrier

The shape that works: the browser is a camera and a display, the server holds the key, AffixIO is the verifier. Nothing in that sentence is optional.

  1. Capture The browser opens the camera or accepts a pasted carrier. Decode it with any QR or barcode library. You now hold a string, not a decision.
  2. Authenticate on your origin A short-lived session cookie or bearer token that your own backend issued to the operator. This is what authorises the next hop, not an AffixIO key.
  3. Forward Your backend calls POST /v1/verify or POST /v1/gate/verify with the AffixIO key and the carrier payload. The key never leaves that host.
  4. Decide The browser receives a boolean, a reason code and whatever of the response you choose to surface. On a 409 it receives a double-spend signal and stops, rather than retrying.
  5. Audit Your backend keeps X-Request-Id, the digest and the reason code. Optionally it listens for the matching webhook or pulls the evidence export later.
// browser: never holds AFFIX_API_KEY
const carrier = await scan();                 // string from the camera
const session = await getOperatorSession();   // your own cookie / token

const result = await fetch("/internal/verify", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Authorization": `Bearer ${session}`,
  },
  body: JSON.stringify({ carrier }),
});

const { admitted, reason_code, request_id } = await result.json();
renderGate(admitted, reason_code, request_id);
// your backend: the only place that holds AFFIX_API_KEY
app.post("/internal/verify", requireOperator, async (req, res) => {
  const upstream = await fetch("https://api.affix-io.com/v1/gate/verify", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": `Bearer ${process.env.AFFIX_API_KEY}`,
      "Idempotency-Key": crypto.randomUUID(),
    },
    body: JSON.stringify({ carrier: req.body.carrier, gate_id: process.env.GATE_ID }),
  });

  const body = await upstream.json();
  res.status(upstream.status).json({
    admitted: body.admitted ?? body.valid ?? false,
    reason_code: body.reason_code,
    request_id: upstream.headers.get("X-Request-Id"),
  });
});

Two separate credentials, two separate lifetimes. The operator session lasts for a shift. The AffixIO key lasts until you rotate it. Conflating them is how keys end up in source maps.

What the browser can do without a key

A few things are public by design, and a few more are safe once the material arrives through your own backend.

Safe browser-side work
TaskHow
Read the current Merkle rootGET https://api.affix-io.com/v1/merkle/root. No key. Useful for a status strip that shows the tree is moving.
Read the ML-DSA-65 public keyGET https://api.affix-io.com/.well-known/affix-mldsa65.json. Cacheable for an hour.
Scan a carrierAny QR or barcode library. The string goes to your backend; the decision comes back from it.
Verify a Merkle inclusion proofFetch the proof through your backend, then fold it with Web Crypto SubtleCrypto.digest using the same domain-separated SHA-256 scheme described on the Python page. The fold itself needs no key.
Display an outcomeYes, no and a reason code. Nothing about the person the check concerned.

CORS, stated plainly

Credentialed requests from arbitrary origins do not receive AffixIO CORS headers. Known AffixIO origins do. A browser page on your domain that tries to call the API with a key will either fail the CORS check or, if you somehow bypassed it, expose the key. Neither outcome is useful. Put the call behind your own origin.

On WASM

People ask for a WASM build for two different reasons, and the answers differ.

To prove in the browser. Zero-knowledge proving is not supported and will not be soon. UltraHonk through Barretenberg is a large native footprint in its current packaging, and shipping it as WASM would be a different product with different testing and different size budgets. Light proving is a different question: @affix-io/sdk-web does it on Web Crypto with no WASM at all. The constraint there is not the runtime, it is the secret. Whoever holds the Light secret can mint proofs, which makes untrusted browser code the wrong place for it and an edge worker you control the right one.

To verify something in the browser. Merkle inclusion is already a handful of SHA-256 calls and works with Web Crypto today. ML-DSA-65 verification is possible in principle once a verified WebAssembly implementation of the algorithm is something you are prepared to maintain; AffixIO does not currently ship one. Until it does, verify signatures on a host you control.

If a WASM prover becomes something AffixIO is prepared to support, it will appear as a versioned package with its own documentation, not as a silent addition to the existing Node packages. Until then, assume proving stays on a host you control.

Questions

Packaging, Light, keys, edge runtimes and WASM.

Is there an AffixIO browser package?

Yes, @affix-io/sdk-web. Web Crypto and Fetch only, no Node APIs, covering Affix Light local prove and verify, REST calls and webhook signature checks in browsers, Cloudflare Workers and Deno. It is distributed as source rather than published on npm. The two Node packages remain Node-only with no browser export.

Is there a WASM build?

No. UltraHonk proving stays on Node.js, and Light proving at the edge uses Web Crypto HMAC, which needs no WASM. Light proofs are not zero knowledge either way.

Are Light proofs zero knowledge?

No. Light proofs are HMAC-SHA256 state proofs. They are fast and useful for millisecond local checks. They are not SNARKs and not zero knowledge. The UltraHonk path in @affix-io/sdk is the zero-knowledge path.

Can a browser call the API directly?

Not with an API key. A key in client JavaScript is a key anyone can copy from DevTools. CORS on the API is restricted to known AffixIO origins for credentialed requests. Keep the key on a server you control.

What can a browser do without a key?

Read the public Merkle root, read the ML-DSA-65 public key, scan a carrier and send it to your backend. Merkle inclusion can be folded client side once the proof arrives through that backend.

How should a web application verify a carrier?

The browser captures the carrier, a short-lived session on your own origin authenticates the operator, and your backend calls AffixIO with the key. The browser receives a boolean and a reason code, never the key.

Will AffixIO ship a WASM prover?

Not today. If a WASM build becomes something AffixIO is prepared to support, it will be announced as a versioned package. Until then, assume proving stays on a host you control.

Next

If you need the Light path, read it on its own page. If you need the zero-knowledge path, start with the full SDK.