# 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 bullet above) stays the first-class path —
nothing here changes that, and your OIDC stack carries on unchanged. If you'd
rather not hand-roll one, the same SDK that verified the deed can mint a
standard ES256 session JWT, signed with **your own key**, decodable by any
JOSE library on the checking side. It's sugar on top of verification, not a
dependency of it.

From the reference MCP server'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 jobs: 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 on the checking side. See
[MCP server auth § Deed → local bearer](mcp-server.md#2-deed-local-bearer-post-authtoken)
for the full route.

Two things to know if you verify the minted session JWT with your own JOSE
stack: pin the algorithm to ES256 (e.g. jose's `importJWK(jwk, "ES256")` /
`algorithms: ["ES256"]`) so alg-confusion is 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`).

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

### Scaling out: a shared challenge store

Replay defence is only as strong as the store behind it. The default
`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, an availability one that looks
like a broken deploy the first time you scale out. Put a shared store (Redis,
your database) behind the `ChallengeStore` interface before then.

`RedisChallengeStore` is the multi-process complement, shipped in all four
languages. It implements the same two operations as the in-memory store,
against a shared backend: `issue` is `SET key "1" PX ttl`, `burn` is an
atomic `DEL key == 1`. The atomic delete is what makes single-use hold across
processes — two instances racing to burn the same challenge cannot both win,
because only one `DEL` returns 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 — you
inject it, in every language — so choosing `RedisChallengeStore` never adds a
package your project didn't already ask for. The in-memory store stays the
default: constructing a `DeedGuard` with no store argument is unchanged.

Fail-closed semantics differ slightly by language: 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.
