The Noir below is simplified for readability.
We build an authentication solution in which security comes from a zero-knowledge proof. One of our circuits verifies a WebAuthn passkey assertion in Noir, and it has two halves. Checking the passkey’s secp256r1 signature is a library call. Checking the contents of the bytes that signature covers is where the work went, because a signature establishes that those bytes are authentic and says nothing about what they say.
This post walks through that second half: how the circuit binds the type, challenge and origin inside clientDataJSON, and the flags inside authenticatorData, to the values the verifier expects; why each check is shaped the way it is; and what the whole thing still assumes.
The signed message
When a relying party calls navigator.credentials.get(), the authenticator signs the following:
message = authenticatorData || SHA256(clientDataJSON)
signature = ECDSA_P256(privateKey, SHA256(message))
clientDataJSON is a UTF-8 JSON object that the browser assembles; the authenticator never sees it directly, only its SHA-256, which it signs alongside authenticatorData. An assertion our circuit accepts looks like this:
{"type":"webauthn.get","challenge":"ULLEP9ORBrr7ug2jT8Qw4fkePJbqKs7ivDQRn5Kzd1A","origin":"http://localhost:5184","crossOrigin":false}
authenticatorData has a 37-byte fixed header; the assertions this circuit accepts are exactly 37 bytes: rpIdHash(32) || flags(1) || signCount(4). rpIdHash is the SHA-256 of the relying-party ID, the domain this credential is scoped to, and stops an assertion minted for one site being replayed against another. flags records user presence (UP), user verification (UV), plus backup-state and extension bits. signCount is a clone-detection signal for some authenticators; many passkey platforms leave it at zero, and our circuit keeps it private.
Verifying the signature establishes one fact: the enrolled authenticator signed this authenticatorData together with the hash of this clientDataJSON. It says nothing about the origin, the challenge, or whether the user was verified. Those values must be bound to their fixed positions in the signed bytes, not merely shown to exist.
The threat model matters. The constraints below are built for an adversary with honest bytes but a dishonest witness: a real browser ceremony produced the signed clientDataJSON, but the prover chooses which ceremony to run, from which origin, with which challenge, and supplies every witness value the circuit consumes afterwards, including all offsets.
That honest-bytes half is a deployment assumption. The authenticator signs the 32-byte hash the client hands it, so whoever computes that hash decides what the signature covers. In a normal login the browser computes it from a genuine ceremony. Whoever can cause the enrolled credential to sign can produce a clientDataJSON they wrote themselves, with any origin and flags they like. The ability to obtain a valid signature is already what an honest login requires; because the key is bound into the enrollment commitment, a forged ceremony reaches only its owner’s account. The rest of this post is about the dishonest-witness half.
Field anchoring
The obvious way to check an origin is to scan the signed bytes for it. That compiles and passes a happy-path test, but it is trivially bypassable: a substring match proves a value appears somewhere in signed JSON, not that the origin field holds that value.
Say the expected origin is https://app.example.com. An attacker controlling a dangling subdomain can stand up https://app.example.com.evil.example.com. That host remains inside example.com, so an honest ceremony can use the same relying-party ID. The browser writes that longer origin truthfully, but a prefix search still returns true.
The rule that fixes it is short: never match a bare value. Match the literal JSON key prefix, then the expected value, then the closing delimiter, all at a declared offset.
fn verify_origin(client_data: [u8; MAX_CLIENT_DATA_LEN], data_len: u32,
origin_index: u32, expected_origin: [u8; MAX_ORIGIN_LEN],
expected_origin_len: u32, expected_origin_hash: [u8; 32]) -> bool {
// b"\"origin\":\""
let prefix: [u8; 10] = [34,111,114,105,103,105,110,34,58,34];
let mut valid = bytes_equal_at(client_data, data_len, origin_index, prefix);
valid = valid & (origin_index + 10 + expected_origin_len < data_len);
for i in 0..MAX_ORIGIN_LEN {
if i < expected_origin_len {
valid = valid & (client_data[origin_index + 10 + i] == expected_origin[i]);
}
}
valid = valid & (client_data[origin_index + 10 + expected_origin_len] == 34);
let origin_hash = sha256_var(expected_origin, expected_origin_len);
for i in 0..32 { valid = valid & (origin_hash[i] == expected_origin_hash[i]); }
valid
}
Four checks do the work. The literal "origin":" prefix must sit at origin_index; the expected origin must follow immediately; a closing quote must terminate it; and sha256(expected_origin) must equal a public policy hash. Without that last binding, the prover could choose its own expected origin and the check would collapse.
The closing quote kills the subdomain attack. After https://app.example.com, the attacker’s origin continues with .evil.example.com, not a quote. The RP-ID comparison does not rescue this case: the hostile host still sits within the credential’s RP-ID scope. That makes exact origin binding the decisive check.
The uniqueness argument has a boundary. The offset is prover-chosen, so it can point anywhere; what pins it down for honest browser bytes is the serializer. A quote inside a JSON string is escaped as \", so the sequence "origin":" cannot appear inside a value. Nor does another WebAuthn field use that key. The circuit itself compares bytes and does not prove its input is JSON. Prover-authored bytes could contain a decoy key or a duplicate key, which is why those cases belong to the honest-bytes deployment assumption rather than to these constraints.
The arithmetic needs a boundary check too. A prover might try to place origin_index near u32::MAX so that adding the prefix and value lengths wraps. In Noir, unsigned arithmetic is checked: the addition fails instead of wrapping.
type has the same shape and is pinned to offset 1, because WebAuthn client data begins with {"type":. crossOrigin is anchored with a leading comma, ,"crossOrigin":false, making it a field boundary rather than a substring. WebAuthn serializes that member even when it is false. A cross-origin ceremony serializes true and can carry a topOrigin, so the circuit rejects cross-origin ceremonies while accepting same-origin iframes.
Challenge encoding
The challenge is variable and security-relevant, so it gets a stronger treatment. Our protocol uses a 32-byte challenge, which WebAuthn serializes as 43 characters of unpadded base64url. Four distinct strings can decode to the same 32 bytes if a decoder accepts non-canonical trailing bits. Instead of decoding and remembering to reject those alternatives, the circuit re-encodes the public challenge and compares the unique canonical result to the signed bytes at the anchored offset.
fn verify_challenge(client_data: [u8; MAX], data_len: u32, idx: u32,
challenge_bytes: [u8; 32]) -> bool {
let prefix: [u8; 13] = [34,99,104,97,108,108,101,110,103,101,34,58,34];
let encoded: [u8; 43] = base64url_32(challenge_bytes);
let mut valid = bytes_equal_at(client_data, data_len, idx, prefix);
valid = valid & (idx + 13 + 43 < data_len);
for i in 0..43 { valid = valid & (client_data[idx + 13 + i] == encoded[i]); }
valid & (client_data[idx + 13 + 43] == 34)
}
This also strengthens anchoring. The challenge is constrained to exactly base64url(challenge_bytes), while the base64url alphabet contains no quote, colon, slash, or full stop. The one variable field that might otherwise be used to plant a decoy fragment cannot carry the relevant JSON punctuation.
Authenticator flags
fn verify_authenticator_data(auth_data: [u8; MAX], expected_rp_id_hash: [u8; 32]) -> bool {
let mut valid = true;
for i in 0..32 { valid = valid & (auth_data[i] == expected_rp_id_hash[i]); }
let flags = auth_data[32];
valid = valid & ((flags & 1) == 1); // UP
valid = valid & ((flags & 4) == 4); // UV
valid = valid & ((flags & 226) == 0); // reject RFU, AT and ED
valid
}
The mask carries the 37-byte assumption. AT (attested credential data) and ED (extension data) signal variable-length data after signCount, so the circuit also asserts authenticator_data_len == 37. Those assertions are rejected rather than read short. Backup eligibility (BE) and backup state (BS) are permitted; the first circuit version deliberately leaves their consistency rule to a conforming authenticator.
Supporting extensions or attested credential data would be a different circuit and a new verification key. That is intentional: the fixed layout is a policy decision, not an accidental parser limitation.
Why not parse JSON?
Fragment checks look fragile, so why not parse JSON in-circuit? Parsing costs a tokenizer pass over every byte and moves, rather than removes, the proof obligation: the parser must then handle escapes, unpaired surrogates, and duplicate names correctly. WebAuthn explicitly provides a byte-comparison algorithm for verifiers that cannot afford a full parser. Ours follows that path, checking anchored fragments rather than one contiguous prefix.
Conclusion
The circuit proves a precise claim: an enrolled passkey signed an assertion for the expected type, challenge, origin, and same-origin context, with user presence and verification required.
It does not prove that a browser produced clientDataJSON. That remains a deployment assumption, just as it does in ordinary WebAuthn verification: the circuit gives signed bytes their meaning; the client is responsible for producing honest bytes.
The code is simplified for exposition.
