docs / guide

view as .md

Verify a deed#

Install the guard, issue a challenge, verify. No server is involved anywhere in this path.

Verifying a deed#

The deed guard is the piece you install. It owns exactly the gap the verifier cannot: the anti-replay challenge is state only your app holds, and a verifier that managed it would be a server — the thing this product removes.

It ships in TypeScript, Python, Go and Rust, with thin adapters for Express, FastAPI and net/http. Rust has no adapter on purpose: it has no single dominant web framework, and wiring the guard into axum, actix or poem is a handful of idiomatic lines either way.

Two endpoints and you are done:

GET  /challenge   → { "challenge": "…" }   the guard mints and remembers it
GET  /anything    ← X-Grantor-Deed: <base64url deed>
                    X-Grantor-Challenge: <the challenge>

Three properties are worth knowing, because they are the difference between the guard and a hand-rolled check:

  • The challenge travels in its own header and is never read out of the deed. Taking it from the token would let the caller pick their own nonce, which is the entire replay defence gone.
  • The challenge is burned before verification, not after. Otherwise a flood of bogus deeds becomes a free probe for which challenges are live. The cost is that a legitimate client whose own deed was malformed must fetch a new challenge, which is the right trade.
  • The guard mints no session and sets no cookie. Your session format, expiry and flags are yours; they are not ours to choose.

Error codes#

The same strings in every language, so your handling ports. Full reference, including the operator-facing origin-vouch self-check codes: Errors.

CodeMeaningHTTP
MissingDeed / MissingChallengethe caller sent neither header401
UnknownChallengeunissued, expired, or already spent401
BadDeedEncodingnot base64url JSON401
UnsupportedModenot a mode this verifier accepts401
WrongAudienceminted for a different app401
ChallengeMismatchbound to a different challenge401
Expiredpast its own exp401
Malformedwrong fields for its claimed mode401
BadProofsignature or ZK proof failed401
StaleRootmembership root too old — possible revocation401
TenantInactivebilling — the tenant has lapsed401
Chainthe RPC read failed503

Chain is 503 rather than 401 deliberately. An RPC outage is not the caller's fault, and answering 401 sends clients into a login loop that discards good credentials and cannot succeed.

The verifier fails closed: if the chain cannot be read, no deed is accepted. That is what makes the on-chain billing check load-bearing rather than advisory.

The default in-memory challenge store#

Correct for one process and wrong the moment you run two — a challenge issued by one instance is not found by the other, so every other login fails. Put a shared store (Redis, your database) behind the ChallengeStore interface before scaling out.

See also#