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 the anti-replay challenge, the one stateful gap the verifier alone cannot cover. It ships in TypeScript, Python, Go and Rust, with thin adapters for Express, FastAPI and net/http (Rust has none — no single dominant framework to adapt to).

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.

Or one call#

Minting your own session JWT the way the bullet above describes stays the first-class path — nothing below changes it, and your OIDC stack carries on unchanged either way. If you would rather not hand-roll one, the same SDK that verified the deed can mint a standard ES256 session JWT too, signed with your own key, decodable by any JOSE library on the checking side. It is sugar layered on top of verification, not a dependency of it.

Excerpt from examples/mcp-server/server.mjs's /auth/token handler, right after g.guard.verify has returned claims:

import { DeedVerifier, sessionJwt } from "../../sdk/verify/ts/pkg/grantor_verify_wasm.js";
    const sessionToken = sessionJwt(
      claims.sub,
      AUDIENCE,
      BigInt(TENANT_ID),
      SESSION_SIGNING_KEY_PEM,
      BigInt(Math.floor(Date.now() / 1000)),
      BigInt(SESSION_JWT_TTL_SECS),
      { iss: ORIGIN, kid: SESSION_KID },
    );

Two artifacts, two honest jobs — the example's own comment puts it exactly this way: the deed authenticated the caller; sessionToken is the "take this into the rest of YOUR stack" artifact, verifiable against SESSION_PUBLIC_JWK (or a published jwks_uri-style endpoint) by anything holding the public key, with no Grantor SDK involved on the checking side. See MCP server auth § Deed → local bearer for the full route this excerpt comes from.

Two things to know if you verify the minted session JWT with your own JOSE stack rather than ours: pin the algorithm to ES256 (e.g. jose's importJWK(jwk, "ES256") / algorithms: ["ES256"]) so alg-confusion is structurally off the table, and there is no jti claim — signing is deterministic, so identical claims minted in the same second yield a byte-identical token, and using the raw token string as a unique session id will collide.

Ships as sessionJwt (TypeScript), session_jwt (Python), SessionJwt (Go) and session_jwt (Rust, grantor_verify::session::session_jwt) — the "session-JWT convenience minting (RP-side)" row in sdk/capability_matrix.py, proven against the shared conformance vectors.

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.

Scaling out: a shared challenge store#

Replay defence is only as strong as the store backing it. MemoryChallengeStore is correct for exactly one process; the moment a second instance joins, a challenge issued by one is invisible to the other and that login fails — not a security hole, but an availability one that looks like a broken deploy the first time you scale horizontally.

RedisChallengeStore is the multi-process complement, shipped in all four languages (sdk/capability_matrix.py's "shared challenge store" row). It implements the same two operations as the in-memory store, just against a shared backend: issue is SET key "1" PX ttl, burn is an atomic DEL key == 1. The delete being one atomic command is what makes single-use hold across processes — two instances racing to burn the same challenge cannot both win, because only one DEL can return 1. Expiry lives in the backend (the PX TTL); there is no sweep to run yourself.

Redis is a choice, not a dependency. No SDK imports a Redis client — the client is injected by you, in every language, so choosing RedisChallengeStore never adds a package your project didn't already ask for. The in-memory store stays the default in all four languages; nothing about this capability changes what you get if you construct a DeedGuard with no store argument.

Fail-closed semantics differ slightly by language, and it's worth stating honestly rather than papering over: a backend outage must never let a login through, but what error comes back varies.

  • Go reports the backend error as Chain/503 — an outage is not the caller's fault, so it gets the same "the network failed" status as an RPC outage, not "you are not authenticated."
  • TypeScript and Python propagate the client's own exception, which surfaces as a 5xx from whatever throws it — there's no ChallengeStore error type to translate into, so the failure is whatever your Redis client raises.
  • Rust's ChallengeKv trait is infallible by design (so adding it isn't a breaking change to ChallengeStore), so a backend error is swallowed at the store and surfaces one layer up as UnknownChallenge/401 — the wrong error class (401 instead of 503) but the same safe direction: denial, never acceptance. If that distinction matters to your ops tooling, monitor the backend directly rather than inferring its health from the verifier's HTTP status.

One construction snippet per language, client already connected:

TypeScript (node-redis v4 — ioredis needs a 3-line wrapper mapping set(k, v, "PX", ms)/del(k) onto the same shape):

import { createClient } from "redis";
import { RedisChallengeStore } from "../../sdk/verify/ts/src/guard.js";

const client = createClient({ url: process.env.REDIS_URL });
await client.connect();

const store = new RedisChallengeStore({ client });

Python (redis.asyncio):

import redis.asyncio as redis
from grantor_verify.guard import RedisChallengeStore

client = redis.Redis.from_url("redis://localhost:6379")
store = RedisChallengeStore(client)

Go (an ~8-line wrapper over go-redis implementing ChallengeKV):

type goRedisKV struct{ c *redis.Client }

func (k goRedisKV) SetPX(ctx context.Context, key, value string, ttl time.Duration) error {
    return k.c.Set(ctx, key, value, ttl).Err()
}

func (k goRedisKV) Del(ctx context.Context, key string) (int64, error) {
    return k.c.Del(ctx, key).Result()
}

kv := goRedisKV{c: redis.NewClient(&redis.Options{Addr: "localhost:6379"})}
g, err := guard.New(v, guard.NewRedisChallengeStore(kv, "", 0))

Rust (ChallengeKv implemented over a redis-rs sync connection behind a Mutex, since the trait is sync — and while there's no bundled adapter, wiring the guard into axum, actix or poem is a handful of idiomatic lines either way):

use grantor_verify::guard::{ChallengeKv, DeedGuard, KvChallengeStore};

struct RedisKv(std::sync::Mutex<redis::Connection>);

impl ChallengeKv for RedisKv {
    fn set_px(&self, key: &str, ttl_ms: u64) -> Result<(), String> {
        redis::cmd("SET")
            .arg(key).arg("1").arg("PX").arg(ttl_ms)
            .query::<()>(&mut *self.0.lock().unwrap())
            .map_err(|e| e.to_string())
    }

    fn del(&self, key: &str) -> Result<u64, String> {
        redis::cmd("DEL")
            .arg(key)
            .query::<u64>(&mut *self.0.lock().unwrap())
            .map_err(|e| e.to_string())
    }
}

let conn = redis::Client::open("redis://127.0.0.1/")?.get_connection()?;
let kv = RedisKv(std::sync::Mutex::new(conn));

// policy/gate/vouch: however your app already constructs them (see the
// Rust composition example in [Sovereign tier](../sovereign-tier.md) for
// `gate`) — unchanged by adding a shared store; only the store argument
// is new.
let guard = DeedGuard::with_origin_vouch(policy, gate, KvChallengeStore::new(kv), vouch);

See also#