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.
| Code | Meaning | HTTP |
|---|---|---|
MissingDeed / MissingChallenge | the caller sent neither header | 401 |
UnknownChallenge | unissued, expired, or already spent | 401 |
BadDeedEncoding | not base64url JSON | 401 |
UnsupportedMode | not a mode this verifier accepts | 401 |
WrongAudience | minted for a different app | 401 |
ChallengeMismatch | bound to a different challenge | 401 |
Expired | past its own exp | 401 |
Malformed | wrong fields for its claimed mode | 401 |
BadProof | signature or ZK proof failed | 401 |
StaleRoot | membership root too old — possible revocation | 401 |
TenantInactive | billing — the tenant has lapsed | 401 |
Chain | the RPC read failed | 503 |
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#
- Getting started — the deed path, end to end.
- Wallet login and Agent tokens — how these deeds get minted in the first place.
- Sovereign tier — discovery, origin binding, origin provenance, and the exact order
verify_deedchecks things in. - Errors — the full error reference.
../llms-full.txt— the entire product in one file.