Skip to main content
Maturity: Limited preview. This flow works end to end today. Preview environments verify against development-ceremony keys, which carry no cryptographic soundness guarantee.
Three pieces of code, in the order the data moves. Assumes you have finished Get access.
The two response paths are the part worth reading twice. A scanned QR means the phone has no idea your page exists, so it posts to your registered endpoint directly. A deep link on the same phone can come back through the page, which is the only reason receiveFragmentResponse exists.

1. Backend: issue a nonce and sign the request

The request is a compact JSON Web Signature (JWS) signed with your registered presenter key. The browser never holds that key, and the nonce must be issued where its single-use burn happens.
Unsigned JSON is refused on the device (unsigned_request). A pairwise-only integration still needs a presenter key.

2. Browser: render it

Both delivery paths carry the same thing, a deep link holding the signed request. Cross-device renders that link as a single static QR code. Same-device navigates to it.
The QR encodes the link, not the bare payload. That is what lets the phone’s native camera open the app directly, with no in-app scanner and no second app to install. A bare payload is a meaningless string to a camera. The scheme is the installed build’s: vibecheck for production, vibecheck-testnet, or vibecheck-dev. It is a required argument, since any default would be the wrong app for someone. The route is array-siop for both sign-in and claim requests, and the app dispatches on the request’s typ. The scheme still reads vibecheck even though the app is called Alive. It is a registered URL scheme rather than a product name, so it is what the installed build actually answers to. Do not substitute alive.
Three QR settings decide whether a code scans first time, and none is about capacity. Fix an integer scale rather than a target width, because a width makes the renderer derive a fractional scale and resample modules into a ragged 2px/3px mix. Use error-correction level L, since a code on a clean display is not defending against physical damage and the redundancy costs modules. Keep the full 4-module quiet zone inside the canvas rather than relying on the surrounding CSS padding.
On a same-device sign-in the app returns the user to your page with the token in the URL fragment. Handle that on whichever page you registered as the return target:
Cross-device sign-ins skip the browser. The phone posts to your response endpoint directly, so there is nothing for the page to do.

3. Backend: verify

Both delivery paths arrive at the same endpoint, one as JSON and one as the form post handOffToBackend sends.

Where roots come from

A membership proof says the user is in the identity tree. On its own that proves nothing, because anyone can build a tree and prove membership of it. So the proof is checked against that tree’s Merkle root, and the root has to be one you accept. Our Indexer signs each root with an operator key and publishes it on a feed. You do not trust the feed. The provider polls it in the background and re-verifies every entry against the operator key you pinned, so a tampering relay can stall you but cannot forge for you. Start it once at boot, not per request.
roots.warm is true once at least one entry has been fetched and verified. Until then there is no root to judge a proof against. A fetch that succeeds but returns nothing leaves the provider cold, which is the ordinary state on a fresh feed deploy rather than an edge case. If you only want authentication, drop idFacetProof from the request in step 1 and from the verify call below. You then need no feed, no pins and no provider, and your ceiling is self_certified. The constructor above is the minimum. Polling clamps, how freshness is decided, and what the provider does with the keys the feed serves are in the RP server reference.

Verify the token

Gate on roots.warm before calling the verifier. A cold roots provider throws rather than returning false, and the right answer is 503, not 401. A cold provider means you could not tell, whereas a 401 tells the device its credential is bad and spends the one retry it has. The Fastify selfIssuedPreHandler does not do this check for you, and by the time it runs the nonce is already consumed.

Verdict tiers

membership_proven is the only tier carrying a sybil guarantee. If your gate cares that one human gets one of something, check for it.

Subject modes

Set by the scope field in step 1. A pairwise subject derives from your client_id, so the same person signing into two applications presents two unrelated subjects. Choose the mode on the server, not in the page, and pass the same mode to verifySelfIssuedToken as expectedMode. A token of the other mode is refused, which stops a pairwise token from passing as a disclosed one.

Next

The full exported surface is in the RP and RP server references.