# 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`:

```js
import { DeedVerifier, sessionJwt } from "../../sdk/verify/ts/pkg/grantor_verify_wasm.js";
```

```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](mcp-server.md#2-deed-local-bearer-post-authtoken)
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](errors.md).

| 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.

### 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):

```js
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`):

```python
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`):

```go
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):

```rust
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

- [Getting started](getting-started.md) — the deed path, end to end.
- [Wallet login](wallet-login.md) and [Agent tokens](agent-tokens.md) — how these deeds get minted in the first place.
- [Sovereign tier](../sovereign-tier.md) — discovery, origin binding, origin provenance, and the exact order `verify_deed` checks things in.
- [Errors](errors.md) — the full error reference.
- [`../llms-full.txt`](../llms-full.txt) — the entire product in one file.
