Sovereign tier — deeds#

The sovereign model is the product: your app authenticates a caller with a deed — a self-certifying credential the caller mints itself, checked directly against the public on-chain GrantorRegistry (the trust anchor + billing ledger). There is no auth server to run or point at, no key custody on Grantor's side, and no forge risk against Grantor, because nothing runs. The only thing your own service takes on is a verifier library dependency and an RPC connection to read the registry.

The sovereign tier has seven wire modes. agent-zk, user-sig, user-passkey, user-1271 and user-zk are the five your app verifies, and one verifier call (verify_deed/verify_deed_claims) handles all five via a mode dispatch — user-1271 is the one exception within that dispatch: it is offline-unverifiable by construction (see Smart-wallet login below), so it runs at the gated layer before the pure claims function ever sees it, rather than inside it. user-zk reuses agent-zk's exact ZK-membership crypto and root-recency check, over a separate, admin-curated user tree instead of the agent tree — see User gating for the full mode, including admin enrollment and revocation. admin-sig is a sixth mode that exists so Grantor's own control-plane dashboard has no account-shaped auth left either — it authenticates Grantor's admins, not your users, and it verifies through a separate, dedicated entry point that verify_deed refuses to accept (see Dashboard login below). admin-sig itself has two holder shapes, an EOA and an EIP-1271 smart-contract wallet (e.g. a Safe) — both mint the same admin-sig wire mode, just verified through two different entry points. zk-chain is the seventh — ⚠️ preview, novel cryptography pending external audit — a ZK proof of an anonymous, bounded-depth delegation chain; it authenticates an (effective) authority commitment rather than a principal, and — like admin-sig — verifies through its own dedicated entry point that verify_deed refuses to accept. See Capabilities § Structure-hiding delegation for the full mode.

ModeWhoProvesOn-chain check
agent-zkan enrolled agentZK membership of the tenant's registry, without revealing which memberroot recency (revocation) + tenant status (billing)
user-zka human enrolled in the tenant's own user allowlistZK membership of that allowlist, without revealing which member — see User gatingroot recency (revocation) + tenant status (billing)
user-siga human with a walletcontrol of a wallet-derived, app-scoped keytenant status (billing) only
user-passkeya human with a passkey (WebAuthn)control of a browser-bound P-256 credentialtenant status (billing) only
user-1271a human with an EIP-1271 smart-contract wallet (e.g. a Safe)on-chain isValidSignature approval by that wallet CONTRACTon-chain isValidSignature (the login check itself) + tenant status (billing)
admin-sigGrantor's own dashboard admin (EOA); an EIP-1271 smart wallet is a verified library capability not yet wired into Grantor's own dashboardcontrol of a wallet addressnone, deliberately
zk-chain ⚠️ previewa delegate at the end of an anonymous, bounded-depth delegation chain rooted at a tenant memberan (effective) authority commitment, hiding hop count and every intermediate key/grantroot recency of the rooting member's tree (revocation) + tenant status (billing)

user-passkey and user-1271 are both additive alongside user-sig, not replacements — see Passkey login and Smart-wallet login below for why each exists and when to pick which.

Every capability, every language#

Both sides ship in all four languages. Minting and verification exist in TypeScript, Python, Go and Rust, and just capability-matrix fails the build if any cell is missing:

TypeScriptPythonGoRust
user-sig holder
user-passkey mint (assembler)
user-passkey browser ceremony (registerPasskey/signInWithPasskey)
user-1271 mint (assembler)
agent-zk holder (ZK proving)
admin-sig holder (EOA)
admin-sig smart-wallet mint (assembler)
Deed verification (all four verify_deed modes)
admin-sig smart-wallet verify (verify_admin_deed_smartwallet)
Deed guard (challenge store, single-use burn)

The browser-ceremony row is deliberately TS-only and is not a matrix violation: there is no non-browser WebAuthn authenticator for any language to wrap, the same shape just capability-matrix already accepts for authenticate()'s Rust exemption ("compose it yourself"). The mint assembler underneath the ceremony — pure bytes-in-deed-out, no live authenticator required — is the four-language capability, and that is what ships in all four languages, TypeScript included (mintUserPasskeyDeed is re-exported at the TS barrel, the same low-level re-export mintUserDeed gets for user-sig) — only the live navigator.credentials ceremony wrapped around it is TS-only.

You do not need to hand-write the glue: the deed guard ships in each language with a challenge store, single-use burn and the shared error codes, plus thin adapters (Express, FastAPI, net/http). Rust has no adapter on purpose — it has no single dominant web framework, and the guard is framework-agnostic there.

Every rejection the guard raises is funnel-shaped by default (agent-native GTM, 2026-08-10): a 401 (not authenticated) carries a WWW-Authenticate: Grantor-Deed realm="<origin>", discovery="/.well-known/grantor-deed" header plus discovery/learn fields in the JSON body — pointing a rejected caller at the exact document that tells it how to mint a deed, and the global onboarding manifest beyond that. A 503 (Chain/QuorumDivergence/ WrongChain/LicenseExpired) gets neither: that class of rejection is an RP/network problem, not the caller's credential, so there is nothing to funnel toward. This is additive on the existing error shape and opt-out per adapter (a single funnelHints-style option), never mandatory — see the funnel design (superpowers/specs/2026-08-10-agent-native-gtm-design.md, not published) and the MCP server guide for a wired example.

Which registry every recipe on this page talks to is a separate axis from the mode table above: every construction/mint call below takes a RegistryRef (Registry.canonical() by default — the SDK pins the canonical shared registry for you, no address anywhere in the standard SDK). A custom registry address is expressible only via Registry.devnet(address) (local development, chain-id-gated) or Registry.dedicated(address, license) (an operator-signed enterprise license) — see Enterprise registries for the full reference.

What it is#

An agent-zk deed proves membership without revealing which member. Minting is mintDeed on the ZkAgent type in every language (grantor_sdk_core::sovereign::mint_deed in Rust).

The Semaphore identity secret never crosses the FFI boundary. Leaking it would retroactively deanonymise every deed that agent ever minted, so the API shape — the agent object owns the secret and only emits proofs — is the mitigation, not a convention.

An enrolled agent holds a Semaphore identity whose commitment is registered in your tenant's on-chain membership tree (the same GrantorRegistry used by Grant C's blind-RSA path — see crates/grantor-issuer/src/http/blind.rs). To authenticate, the agent:

  1. fetches the tree's current event log and rebuilds its Merkle membership path from chain (grantor_sdk_core::treesync::build_membership_proof — the tree-sync path; the same code an issuer-backed agent uses to catch up),
  2. produces a ZK proof of membership bound to your challenge + audience + expiry (mintDeed, or grantor_sdk_core::sovereign::mint_deed in Rust),
  3. hands you that proof. There is no token exchange, no redirect, no issuer round-trip — the proof itself is the credential.

RP integration recipe#

Verification ships as DeedVerifier in TypeScript, Python and Go, and as grantor_verify::sovereign::verify_deed in Rust. Chain reads stay on the Rust side of the FFI in every binding: the on-chain tenant check is the billing enforcement, so the shim owns the gate and claims cannot be obtained without passing through it.

Errors are a structured enum rather than a flat string, so a relying party can tell TenantInactive (billing — the customer must top up) from BadProof (an attack) from Chain (an RPC problem worth retrying, and the one case that should answer 503 rather than 401).

For Rust, add grantor-verify with the sovereign-chain feature (non-default — it pulls in semaphore-rs + alloy's chain RPC stack, so light builds that only verify standard OIDC JWTs stay lean). The pure sovereign feature underneath it — envelope, claims verification, DeedGuard, discovery, session-JWT, and the chain PORT itself — links no RPC stack at all; reach for it directly only if you're supplying your own DeedChainGate instead of the built-in AlloyGate:

# `grantor-verify` is currently path-only within this workspace, not yet
# published to crates.io — this snippet is how another crate in the SAME
# checkout depends on it today; publishing is tracked separately.
grantor-verify = { path = "...", features = ["sovereign-chain"] }

The recipe is: issue a challenge → verify → mint a local JWT → your existing OIDC stack is unchanged. This is lifted directly from the working reference RP in crates/grantor-verify/tests/sovereign_e2e.rs (the file that proves the whole tier with zero grantor-issuer process running):

The DeedGuard owns the part verify_deed deliberately cannot: the anti-replay challenge is state only your app holds, and a verifier that managed it would be a server. It issues challenges, remembers them, burns each exactly once, and stops there — minting a session stays yours.

// GET /challenge — the guard mints and remembers it.
#[handler]
fn issue_challenge(Data(state): Data<&Arc<RpState>>) -> Json<serde_json::Value> {
    let mut raw = [0u8; 16];
    rand_core::OsRng.fill_bytes(&mut raw);
    Json(serde_json::json!({ "challenge": state.guard.issue_challenge(raw) }))
}

// GET /resource — verify the presented deed, then mint YOUR OWN JWT.
#[handler]
async fn resource(req: &Request, Data(state): Data<&Arc<RpState>>) -> PoemResult<Json<serde_json::Value>> {
    // The deed and the challenge arrive in SEPARATE headers. The challenge is
    // never read out of the deed — doing so would let the caller choose their
    // own nonce, and the replay defence would be decorative.
    let deed_header = req.header(guard::DEED_HEADER)
        .ok_or_else(|| Error::from_status(StatusCode::UNAUTHORIZED))?;
    let deed_json = guard::decode_deed(deed_header)
        .map_err(|_| Error::from_status(StatusCode::UNAUTHORIZED))?;
    let challenge = req.header(guard::CHALLENGE_HEADER);

    let now = now_secs();
    // Burns the challenge BEFORE verifying, so a flood of bogus deeds cannot
    // probe which challenges are live. `Chain` maps to 503, everything else
    // to 401 — an RPC outage is not the caller's fault.
    let claims = state.guard.verify(Some(&deed_json), challenge, now)
        .await
        .map_err(|e| Error::from_status(
            StatusCode::from_u16(e.status()).unwrap_or(StatusCode::UNAUTHORIZED),
        ))?;

    // Your existing OIDC stack, unchanged: sign your own JWT, carrying
    // sub/aud through. `iss`/`iat` are YOUR OWN — most JOSE/OIDC middleware
    // expects both (iss especially), so a real integration should set them
    // even though `grantor_verify::DeedClaims` itself carries neither
    // (there is no issuer to have asserted an `iss`, and no token mint time
    // to report as `iat` — both are meaningless upstream of your own mint).
    let jwt = jsonwebtoken::encode(
        &jsonwebtoken::Header::default(),
        &RpClaims {
            iss: "https://your-rp.example".to_string(),
            sub: claims.sub,
            aud: claims.aud,
            iat: now,
            exp: claims.exp,
        },
        &jsonwebtoken::EncodingKey::from_secret(&state.jwt_secret),
    ).map_err(|_| Error::from_status(StatusCode::INTERNAL_SERVER_ERROR))?;
    Ok(Json(serde_json::json!({ "jwt": jwt })))
}

state.gate is CachedGate<AlloyGate>AlloyGate::new(rpc_url, registry_address, expected_chain_id) (the RESOLVED address, not a RegistryRefAlloyGate is the lower layer DeedVerifier::new builds on top of after resolving one; see Enterprise registries § Enforcement posture for why composing AlloyGate directly bypasses the license gate) wrapped in a short-TTL cache (grantor_verify::sovereign_gate), constructed once at startup and shared across requests. It is the only network dependency this path has: your own RPC endpoint into the chain the registry lives on.

Multi-RPC quorum#

With one provider, that provider's word about the registry IS the verifier's view of it: a compromised or merely lying node can report a lapsed tenant active, or a revoked root recent, and nothing notices. QuorumGate composes N independent providers and requires unanimity on every chain read — every read goes to every provider, and one honest provider vetoes a lie. DeedVerifier::new(...) is the degenerate case, a quorum of one.

The guarantee is exactly as strong as provider independence, no stronger. "Even your RPC provider can't lie to your verifier" is only true when at least one configured provider is both honest and independent. If every configured provider agrees on the SAME false answer — compromised, colluding, or several endpoints resolving to one shared upstream — quorum sees unanimity and passes it; there is nothing to disagree with. What unanimity actually buys is that ONE honest, independent provider vetoes a lie the others tell; it buys nothing against a lie all N happen to agree on. Pick providers that don't share infrastructure, or this reduces to a single point of failure with extra steps.

Two ways a quorum read can fail, and both fail closed to the same 401/503 split every other chain read uses:

  • Chain (HTTP 503) — a member errored or was unreachable. Same mapping as the single-provider case.
  • QuorumDivergence (HTTP 503) — every member answered, and they disagreed. This is the attack signal the feature exists to produce — alert on it. A single occurrence can be innocent: two providers sitting at different block heights right after a state change (a drawPeriod flipping a tenant active, say) can diverge on one read and agree again on the next, because CachedGate never caches an error — agreement is re-established fresh every TTL window, never remembered stale. A sustained or repeated divergence is the one worth paging on: a provider that keeps disagreeing with the others is either broken or lying, and a verifier has no way to tell those apart from outside — that judgment call belongs to whoever operates the RP, which is why this stays an error the RP observes rather than something the library resolves for you.

The trade is availability, deliberately. Any one member down fails the whole read closed, so a quorum verifier is only as available as its least-available member, never its best one. That is the point, not a defect: the trust property costs exactly what an independently-failing member costs you. Pick 2-3 providers that are actually independent — different infra, different operators. Two endpoints behind the same upstream aren't a quorum; they're one provider with a proxy in front, and they will never produce a QuorumDivergence worth trusting.

Construction takes the same parameters as the single-URL constructor, with rpc_url replaced by a list:

use grantor_verify::verifier::DeedVerifier;

let verifier = DeedVerifier::new_quorum(
    &["https://rpc-a.example".to_string(), "https://rpc-b.example".to_string()],
    &registry_ref, chain_id, tenant_id, audience, origin, max_ttl_secs, cache_ttl_secs,
    allow_insecure_origin, now_unix,
)?;
import { DeedVerifier } from "../../sdk/verify/ts/pkg/grantor_verify_wasm.js";
import { Registry } from "../../sdk/verify/ts/src/registry.js";

const verifier = DeedVerifier.newQuorum(
  ["https://rpc-a.example", "https://rpc-b.example"],
  Registry.canonical(), CHAIN_ID, TENANT_ID, AUDIENCE, ORIGIN, MAX_TTL_SECS, CACHE_TTL_SECS,
  ALLOW_INSECURE_ORIGIN, Math.floor(Date.now() / 1000),
);
from grantor_verify.grantor_verify_uniffi import DeedVerifier
from grantor_verify import registry

verifier = DeedVerifier.new_quorum(
    rpc_urls=["https://rpc-a.example", "https://rpc-b.example"],
    registry_ref=registry.canonical(), chain_id=CHAIN_ID, tenant=TENANT_ID,
    audience=AUDIENCE, origin=ORIGIN,
    max_ttl_secs=MAX_TTL_SECS, cache_ttl_secs=CACHE_TTL_SECS,
    allow_insecure_origin=ALLOW_INSECURE_ORIGIN, now_unix=NOW_UNIX,
)
verifier, err := grantor_verify_uniffi.DeedVerifierNewQuorum(
    []string{"https://rpc-a.example", "https://rpc-b.example"},
    guard.RegistryCanonical(), chainID, tenantID, audience, origin, maxTtlSecs, cacheTtlSecs,
    allowInsecureOrigin, nowUnix,
)

A bare Rust DeedGuard that supplies its own gate instead of going through DeedVerifier composes the two underlying types directly — CachedGate on the outside, so agreement is re-checked every TTL window rather than remembered. This composition bypasses the enterprise-license gate (see Enterprise registries § Enforcement posture): AlloyGate::new takes an already-resolved address, not a RegistryRef, so nothing here checks a license — resolve a RegistryRef yourself first (grantor_sdk_core::resolve_registry) if you need that check on this path:

use grantor_verify::sovereign_gate::{AlloyGate, CachedGate, QuorumGate};

let gate = CachedGate::new(
    QuorumGate::new(vec![
        AlloyGate::new(rpc_a, registry, expected_chain_id),
        AlloyGate::new(rpc_b, registry, expected_chain_id),
    ]),
    cache_ttl_secs,
);

Production note on state.challenges: the example above is a plain HashSet<String> that only ever grows on issue and shrinks on successful redemption — an abandoned challenge (issued, then never redeemed) stays in the set, and stays redeemable, forever. A production RP needs TTL/eviction on this store (e.g. an expiring cache keyed by challenge, or a periodic sweep) so an unused challenge cannot be redeemed arbitrarily far in the future and memory doesn't grow unbounded under abandoned sessions.

Discovery — how an agent finds all this#

An application that accepts deeds publishes /.well-known/grantor-deed:

{ "v": 1, "tenant": 42, "audience": "api.example.com", "modes": ["user-sig", "agent-zk"], "challenge_endpoint": "/auth/challenge", "max_ttl_secs": 300, "chain": { "id": 42161, "registry": "0x…" }, "origin_vouch": { "signature": "…", "epoch": 0, "exp": 1234567890 } }

origin_vouch is REQUIRED — parse_discovery rejects a document without it, and the builder additionally refuses to emit one whose vouch has expired or whose TTL exceeds the 90-day ceiling. See Origin provenance.

The guard serves it from the same DeedPolicy it verifies against, so what is advertised cannot drift from what is enforced. The TypeScript (grantorExpress) and Python (GrantorDeps) adapters mount it for you when you pass app plus challengeEndpoint/chainId/modes. Go does not auto-mount — its adapter deliberately owns no router (unlike Express/FastAPI, Go has no single dominant one to couple to), so it stays a zero-dependency package; call guard.Discovery(...) yourself and register the returned http.HandlerFunc on whatever mux you use.

An agent needs only the origin and a way to sign — authenticate(origin, signMessage) (authenticate in TS/Python/Go) composes discover → challenge → mint for the user-sig mode, the mode an autonomous agent uses (permissionless, no enrolment round trip). It ALSO requires a chainReader (an eth_call seam) and a registry/rpcUrl pair (the RegistryRef you trust, resolved via a live eth_chainId read over rpcUrl — defaults to Registry.canonical(), the SDK pins the canonical registry for you; a custom address exists only on the licensed dedicated path) — see Constructing a chainReader below for where these come from and why both are required together:

import { authenticate } from "@grantor/agent";
import { Registry } from "@grantor/agent/registry";

const deed = await authenticate("https://api.example.com", signMessage, {
  chainReader,
  registry: Registry.canonical(),
  rpcUrl,
});
from grantor_agent.authenticate import authenticate
from grantor_agent import registry

deed = await authenticate(
    "https://api.example.com", sign_message,
    chain_reader=chain_reader,
    registry=registry.canonical(),
    rpc_url=rpc_url,
)
deed, err := authenticate.Authenticate("https://api.example.com", sign, &authenticate.Options{
	ChainReader: chainReader,
	Registry:    guard.RegistryCanonical(),
	RpcURL:      rpcURL,
})

The chainRegistry/chain_registry/ChainRegistry option each language used to take (a plain, holder-pinned address) is deleted — passing it now throws/raises/errors naming the replacement, rather than silently falling through to the canonical default. Python's authenticate() is now async (it used to be synchronous) — call it with await, matching TS/Go's already-async shape.

authenticate() takes only the origin — there is no second URL parameter for the challenge endpoint. The challenge is always resolved against the same origin discovery was fetched from, structurally: a caller cannot discover at one origin and authenticate at another, because there is nowhere to pass a different one in. If the application does not accept deeds at all (404 on discovery), or does not accept user-sig specifically, it refuses before ever asking signMessage to sign, naming the modes the application does accept.

Constructing a chainReader#

chainReader/chain_reader/ChainReader is the ONE eth_call seam authenticate()/signInWithDeed() use to check origin provenance before signing anything — see Origin provenance below for what it protects against. It is entirely caller-supplied and MUST NEVER be derived from the discovery document (a hostile origin would simply name its own node). registry/rpc_url — resolved to the registry address chainReader is queried against — is REQUIRED alongside it: the document's own chain.registry is compared against this resolved pin and a mismatch is refused, never used as the eth_call target directly (see Origin provenance for why: a hostile origin can publish any contract it likes as chain.registry, including one whose isOriginVoucher always answers true). registry defaults to Registry.canonical() — the SDK pins the canonical registry for you; get a custom address the same way you get the RP's expected tenantId/audience — out of band, from the RP's own documentation or operator, never from anything the RP's discovery document says about itself — and a custom registry address on a real chain requires an enterprise license (Registry.dedicated(address, license)); the standard SDK cannot express one otherwise.

Browser (window.ethereum) — a wallet provider already speaks this shape:

// `data` arrives as a Uint8Array — no `Buffer` here on purpose: Vite and
// webpack 5 don't polyfill Node globals, so this is the one recipe on this
// page that actually has to run in a browser, not just compile.
const toHex = (bytes) =>
  "0x" + Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");

const chainReader = {
  call: ({ to, data }) =>
    window.ethereum.request({
      method: "eth_call",
      params: [{ to, data: toHex(data) }, "latest"],
    }),
};

Server-side (TypeScript/Node) — a minimal hand-rolled eth_call over any JSON-RPC endpoint (this is exactly why chainReader is a raw eth_call seam rather than an alloy/ethers dependency — it keeps @grantor/agent free of a multi-MB chain library):

async function makeJsonRpcChainReader(rpcUrl) {
  return {
    call: async ({ to, data }) => {
      const res = await fetch(rpcUrl, {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({
          jsonrpc: "2.0", id: 1, method: "eth_call",
          params: [{ to, data: "0x" + Buffer.from(data).toString("hex") }, "latest"],
        }),
      });
      const { result } = await res.json();
      return result;
    },
  };
}

Server-side (Python):

import requests

def make_chain_reader(rpc_url):
    def chain_reader(to: str, data: bytes) -> bytes:
        resp = requests.post(rpc_url, json={
            "jsonrpc": "2.0", "id": 1, "method": "eth_call",
            "params": [{"to": to, "data": "0x" + data.hex()}, "latest"],
        })
        return bytes.fromhex(resp.json()["result"][2:])
    return chain_reader

Server-side (Go):

type jsonRPCChainReader struct{ rpcURL string }

func (r jsonRPCChainReader) Call(to string, data []byte) ([]byte, error) {
	body, _ := json.Marshal(map[string]any{
		"jsonrpc": "2.0", "id": 1, "method": "eth_call",
		"params": []any{map[string]string{"to": to, "data": "0x" + hex.EncodeToString(data)}, "latest"},
	})
	resp, err := http.Post(r.rpcURL, "application/json", bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	var out struct {
		Result string `json:"result"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return nil, err
	}
	return hex.DecodeString(strings.TrimPrefix(out.Result, "0x"))
}

Rust uses AlloyGate directly — see the Rust composition example below; it already implements the equivalent seam over alloy, since a Rust holder is not paying the wasm-bundle cost chainReader exists to avoid for the other three languages.

That same origin argument is also what gets signed — never a value read out of the discovery document. authenticate() passes it straight through to mintUserDeedFromWallet as the origin parameter (immediately after aud in every language), so the deed is bound to the origin the agent actually talked to, not to anything the document claimed. See Origin binding below for why this matters.

For agent-zk (an already-enrolled agent proving ZK membership — see Minting an agent-zk deed above), authenticate() does not apply: compose discover() and a challenge fetch by hand, then call ZkAgent.mintDeed yourself:

const d = await discover("https://api.example.com"); if (!d) return; // does not accept deeds const res = await fetch(new URL(d.challenge_endpoint, origin)); const { challenge } = await res.json(); // registryAddress and rpcUrl are YOUR configuration. Never d.chain.registry: // a document-supplied eth_call target lets a hostile origin point the // provenance check at a contract it controls, which answers true. if (d.chain.registry.toLowerCase() !== registryAddress.toLowerCase()) return; const v = d.origin_vouch; // registryAddress is wrapped in a RegistryRef only at the mint call — // Registry.canonical()/Registry.dedicated(addr, license) for a real // deployment; Registry.devnet(addr) shown here matches the pinned // comparison above (a devnet address is what most local recipes pin). const deed = await agent.mintDeed( rpcUrl, Registry.devnet(registryAddress), d.tenant, d.audience, origin, challenge, exp, v.signature, v.epoch, v.exp, false, // allowInsecureOrigin — true only for local dev against http/localhost Math.floor(Date.now() / 1000), );

mintDeed verifies origin provenance before it proves. It takes the tenant admin's vouch and checks it against the registry you pinned, and refuses before generating a proof if the origin cannot show one — mirroring what authenticate() does for user-sig. This is what closes Origin binding items 3 and 4.

mintDeed also enforces the fail-closed origin policy before any of that. allowInsecureOrigin (default-secure false) is forwarded to enforce_origin_policy, checked right after origin is canonicalised and before origin provenance or any chain call: a non-HTTPS or non-routable origin is refused with InsecureOrigin rather than building (and discarding) a real Semaphore membership proof for a deployment that could never be production. Pass true only for local dev / a demo against http://localhost:....

Without this check, a hostile origin publishing a victim tenant's tenant/audience could induce a holder composing this recipe to hand over a valid Semaphore membership proof for the victim's registry plus a nullifier comparable across every origin sharing that (tenant, audience). There is no authenticate()-style wrapper for agent-zk to hide such a gap in — mintDeed is the only agent-zk mint API there has ever been, so the check lives here.

⚠️ Two things you must still get right, because they are yours, not the SDK's. registryAddress and rpcUrl must come from YOUR configuration. Passing d.chain.registry makes the pin vacuous and the SDK cannot tell — it never sees the document. Compare the document's value against your pin and refuse on mismatch, as the snippet above does; authenticate() is structurally safe here only because it reads the document itself and does that comparison for you.

Fetch discovery from the same origin you will present the deed to, and pass that SAME origin — never d.audience or anything else out of the document — as mintDeed's origin argument. See Origin binding below for why: without it, composing this by hand is exactly where a caller could accidentally (or be tricked into) signing the wrong origin, since authenticate()'s structural guarantee no longer applies once you're composing the calls yourself.

Rust has no authenticate(). grantor-sdk-core is deliberately HTTP-free — that is what keeps it wasm-clean, and just sdk-wasm-check guards it in CI — so there is nothing in Rust to fetch a URL with. A Rust holder composes the flow by hand. This is composition, not a missing capability: just capability-matrix's Rust column is marked "compose it yourself" for this one row rather than a bound function, because there is no HTTP client to bind.

But composing it means composing the SECURITY CHECK too, not just the happy path. What authenticate() does for the other three languages, in order:

  1. Canonicalise your own origin (normalize_origin) — once, before any fetch, so the URL you fetch and the origin you sign can never diverge.
  2. Fetch discovery::DISCOVERY_PATH from that origin with your own client, and validate the response through discovery::parse_discovery. Never hand-parse — that validator is shared with every other language on purpose.
  3. Origin provenance, BEFORE anything is signed (Origin provenance): recover the vouch signer with originvouch::origin_vouch_signer(tenant, audience, YOUR_OWN_ORIGIN, epoch, exp, sig), reject exp <= now and exp - now > ORIGIN_VOUCH_MAX_TTL_SECS, then confirm isOriginVoucher on-chain and refuse if it is false or the read fails. Pass your OWN observed origin, never the document's — that substitution is the entire attack this defeats. Read against a registry address YOU pinned out of band, never chain.registry from the document, and over an RPC endpoint of your own: a hostile origin publishes whatever contract it likes there, including one whose isOriginVoucher always answers true.
  4. Only then fetch the challenge endpoint the document names, and mint — usersig::derive_root_seed(origin, …)derive_app_key(seed, tenant, origin)mint_user_deed(…) (see User login), or sovereign::mint_deed for agent-zk.

Step 3 is the one that is easy to leave out, and leaving it out is silent — everything still works, against every origin. crates/grantor-verify/tests/sovereign_e2e.rs composes exactly this (holder_check_origin_provenance / holder_authenticate_user_sig) against a live chain and asserts the signing callback is never invoked for an unvouched origin; treat it as the reference implementation of this list.

Fail-closed origin policy#

Separate from origin binding (below) and origin provenance (above): this is about whether an origin is even well-formed enough for production, not about who it is cryptographically tied to or vouched for. enforce_origin_policy(origin, allow_insecure_origin) (grantor_sdk_core) refuses two things by default — a scheme other than https, and a non-routable host (localhost/*.localhost/*.local/0.0.0.0, and loopback/private/link-local/ULA IPv4 and IPv6 literals). normalize_origin's structural check (a malformed scheme, an embedded |, userinfo, a real path) still runs first regardless of the flag and surfaces as SdkError::Input/DeedError::Malformed; only a well-formed-but-insecure origin gets SdkError::InsecureOrigin/ DeedError::InsecureOrigin.

The default is secure everywhere the parameter exists. allow_insecure_origin/ allowInsecureOrigin defaults to (and should stay) false in any real deployment; a service configured with http://localhost:3000 fails loudly at boot/mint time rather than silently accepting a non-routable, non-HTTPS origin. Set it true only for local dev or a demo — every recipe on this page that talks to http://localhost passes true explicitly, never by omission (see docs/deploy/demo.md's note on deploy.sh computing it from $ORIGIN for exactly this reason).

It is enforced at exactly four call sites, independently, because there is no single point in the code path that sees every origin:

  1. The verifier's own configured originDeedPolicy.allow_insecure_origin / DeedVerifier's allowInsecureOrigin constructor argument (second-to-last, immediately before nowUnix). Checked as literal step 0 of verify_deed_claims/verify_user_1271 — before the mode check, before the deed's own origin comparison, before either chain read. See What the verifier checks below and Errors.
  2. The discovery documentdiscovery_document (grantor-verify) runs the same check on policy.origin before assembling the document at all, so a misconfigured RP never advertises a document a holder could authenticate against.
  3. The origin vouch a tenant admin signsorigin_vouch_binding (grantor_sdk_core::originvouch) refuses to build the vouch message for an insecure origin, so an admin cannot sign a vouch for http://localhost that ends up attached to a real tenant.
  4. Minting an agent-zk deedmint_agent_deed/ZkAgent.mintDeed (grantor_sdk_core::agent) checks it right after canonicalising origin and before origin provenance or any chain call, refusing before building (and discarding) a real Semaphore proof for a deployment that could never be production.

Deliberately NOT enforced: the raw user-sig/user-passkey mint bindings (mint_user_deed/mint_user_passkey_deed take no allow_insecure_origin parameter at all — for user-sig the security-relevant enforcement point is authenticate()'s own config plus the verifier's policy, not the bare mint call), and admin-sig (its binding takes no origin argument in the first place — see "admin-sig is deliberately exempt" under Origin binding just below).

Origin binding#

Every user-sig/agent-zk mint call and every DeedVerifier take an origin — the scheme+host+optional-port the holder actually talked to (https://api.example.com; a path is rejected, but a bare trailing slash is tolerated and folded — see below). It sits immediately after aud/audience in every mint call and every verifier constructor, in every language, so a transposition between the two is visually obvious.

It joins the SIGNED material, not the wire envelope. user_binding (user-sig) and sovereign_signal_hash (agent-zk) fold origin in alongside tenant/aud/challenge/exp; Deed itself gains no origin field — a value the holder supplied and the verifier trusted would defeat the whole point. DeedVerifier/DeedPolicy carry origin as your own configuration, normalised (normalize_origin — lowercases the host, strips a default port, tolerates a single bare trailing slash) so two differently-spelled configurations of the same origin cannot silently diverge: DeedVerifier/DeedVerifierJs normalise once at construction, grantor_verify::sovereign::verify_deed_claims normalises DeedPolicy.origin on every call (so a bare Rust struct literal gets the same protection), and every mint entry point (mintUserDeed/mint_user_deed/MintUserDeed, mintDeed, mintUserDeedFromWallet) canonicalises origin before signing — including both functions a Rust holder calls directly, grantor_sdk_core::usersig::mint_user_deed (see the snippet below) and its agent-zk twin grantor_sdk_core::sovereign::mint_deed. The first is why mint_user_deed is fallible rather than the plain Deed-returning function it started as. Both were once exceptions to this sentence, which is the reason the sentence now names them: the claim was written before it was true — so a holder's https://API.example.com/ and an RP's https://api.example.com — the same origin, spelled two ways a browser or an operator could equally produce — bind the SAME credential rather than silently failing at the signature. normalizeOrigin/normalize_origin/ NormalizeOrigin is exported from every holder-side binding so a caller can canonicalise once, up front, before either fetch — see authenticate() below. Verification rebuilds the binding from the normalised configured origin and the challenge you issued; it never reads an origin back out of the token.

Why this exists. Before origin joined the signed material, a hostile origin A could publish victim B's tenant/audience in its own discovery document, relay a challenge it fetched from B, and collect a deed: the holder signs against B's genuine tenant/audience/challenge with its real key for B, so the signature/proof is entirely valid — it just says nothing about WHERE the holder was. A replays that deed to B, whose verifier saw a correct audience, a live challenge it issued, and a valid signature/proof — full impersonation, no cryptographic material forged. Binding the origin closes it: B's verifier only ever rebuilds the binding with B's own origin, so a deed signed while talking to A can never reconstruct that same binding, and fails as BadProof.

What this closes, and what still does not. Four separate claims, kept separate on purpose — conflating them is exactly the overclaim this section existed to correct once already:

  1. Impersonation: closed. As above — B's verifier only ever rebuilds the binding from its own configured origin, so a deed signed while the holder was talking to A can never reconstruct it and fails as BadProof.
  1. user-sig pseudonym harvesting: closed. derive_app_key folds origin into its HKDF info (tenant:{t}|origin:{o}, usersig.rs; aud is deliberately not part of that scope — see "User login" below), so the app key — and therefore pubkey/sub — is scoped to the origin the holder actually derived against. A publishing B's tenant/audience and getting a holder to authenticate no longer collects B's pseudonym; it collects a pseudonym scoped to A, unrelated to the one B would see. Since user-sig carries no membership proof, that pseudonym is the entirety of what user-sig could ever leak to A — so origin-scoping the key closes the harvest completely, not just partially.

⚠️ Note the boundary: this is about what a hostile origin learns from a holder that derives correctly. It is not a defence against a hostile origin that obtains the root seed, because the origin is an argument the deriving party supplies. That is closed separately, by origin-scoping the SEED itself — see "User login" below.

  1. agent-zk cross-origin linkability: closed by prevention. sovereign_external_nullifier deliberately still scopes on (tenant, audience) only — origin was considered and NOT added to it, for two reasons. First, audience already separates different relying parties, so adding origin would buy separation only in the narrow case of two origins sharing one tenant and audience — essentially just the hostile-origin case this section is about. Second, origin-scoping it would break the property that the sovereign tier's sub equals the blind tier's app_scoped_pseudonym (blindclient.rs) — the mechanism by which an agent recognised on one tier is recognised as the same principal on the other. This leaves a hostile origin A that publishes victim B's tenant/audience able to collect a nullifier that repeats every time the same agent authenticates to A, comparable against nullifiers seen elsewhere sharing that same (tenant, audience). Origin provenance below closes this by prevention — a holder that checks provenance before minting never authenticates to A at all, so there is no nullifier for A to collect.

That check runs on the agent-zk mint path: ZkAgent.mintDeed takes the vouch and verifies it against the caller's PINNED registry before generating a proof, in all four languages — mintDeed is the only agent-zk mint API and no authenticate()-style wrapper exists for it, so the check lives there. The ordering, not merely the error, is pinned per language; see "How the ordering is proven" below.

  1. agent-zk membership disclosure: closed by prevention. Without this check, A would additionally collect a valid Semaphore proof that the holder is enrolled in B's on-chain registry — evidence of membership, not merely a pseudonym. No signed-material binding could ever close this on its own: demonstrating membership is what the proof is for, and scoping what a proof is bound to changes who can use the proof, not whether an untrusted origin can extract the fact of membership by asking a holder to produce one at all. Origin provenance closes it the only way it could be closed — by stopping the holder from ever producing that proof for A. As with item 3, this rests entirely on mintDeed refusing before it proves, which is why the tests assert that no membership proof was built rather than merely that an error was raised.

### How the ordering is proven

Refusing "at some point" would be worthless here: a proof generated and then discarded has still been built, and building it tells the RPC endpoint which tenant the agent belongs to. So each language asserts that proving was never entered, and each assertion was verified by moving the check after build_membership_proof and confirming that specific test fails:

  • TypeScript / Python / Go use a deliberately unregistered agent against the hostile origin. If proving ran first the failure would be NotAMember; getting BadOriginVouch instead is what proves the order.
    • Rust has no ZkAgent — a holder composes the recipe by hand — so hostile_origin_is_refused_before_an_agent_zk_proof_is_built in crates/grantor-verify/tests/sovereign_e2e.rs counts membership fetches against a live anvil and requires zero.
    • Each also mints successfully at the genuinely vouched origin, so none of the above can pass because provenance is simply broken shut.

Both agent-zk items above close through Part B (origin provenance): the tenant admin vouches offline that an origin speaks for the tenant, and the holder verifies that on-chain before signing or proving, so the interaction with a hostile origin never happens in the first place — prevention, not scoping after the fact. Part B covers both the user-sig holder path (authenticate() and signInWithDeed()) and the agent-zk mint path (ZkAgent.mintDeed), in all four languages — see items 3 and 4 above for the ordering proof (provenance-before-proving, not merely provenance-before-return, pinned per language). The agent-zk half of the check lives once, in grantor_sdk_core::agent::mint_agent_deed (crates/grantor-sdk-core/src/agent.rs), which every language's ZkAgent.mintDeed marshals into rather than duplicating the check per shim. The check is still holder-side, not RP-enforceable — see "Only as good as a holder that runs the check" below. See Origin provenance below for what this closes, what it still does not, and its cost.

This enforces nothing. verify_user_sig recomputes sub from the pubkey alone; it has no way to observe which origin a holder derived against, so origin-scoping is not something a verifier checks or can check — it is a property a holder gets for free by deriving correctly, and loses if it (or a hostile origin's SDK fork) derives against the wrong origin. It is a privacy property for the holder, not an authorization control, and it adds no gate a verifier enforces.

Cost. A relying party that legitimately serves one audience from two origins (a staging origin and a production origin sharing a tenant and audience, say) now has its users derive two different user-sig pseudonyms from the same wallet — one human looks like two accounts. That is the same tradeoff a browser's same-origin policy makes for cookies and storage, not a defect specific to this design.

authenticate(origin, ...) makes the fix structural, not just available. It canonicalises origin ONCE, at the top, before EITHER the discovery fetch or the challenge fetch, and uses that SAME canonical value for both fetches AND the mint call — never anything read out of the document. A document cannot influence what origin ends up in the deed, because the document is never consulted for it, and a malformed origin (a real path, userinfo, …) is refused before any network call rather than surfacing as an undiagnosable BadProof two fetches later.

Redirects do not change what gets signed. All three SDKs' discovery and challenge fetches follow redirects by default (plain fetch/requests/ net/http behaviour) and none of them reads the response's final URL for anything — origin is signed exactly as the CALLER supplied it (after canonicalisation), never re-derived from wherever the fetch actually landed. This is a deliberate choice, not an oversight: binding to the final URL's origin would mean a redirect an attacker controls (or one an operator adds later for an unrelated reason — a www. canonicalisation, a load balancer migration) silently changes what a credential is bound to, with no visibility to the caller. The origin you pass to authenticate() MUST be the one your RP is actually configured with (its DeedVerifier/DeedPolicy.origin) — if discovery or the challenge fetch redirects to a different origin, the deed still signs the ORIGINAL one, and verification then depends on which origin the RP is configured with, not on where the fetch happened to end up.

admin-sig is deliberately exempt. Its binding is human-readable prose that already leads with the requesting domain ("Only sign it if you are on {aud} right now") and it never appears in a discovery document's modes, so it is unreachable by the attack origin binding closes — see Dashboard login.

Origin provenance#

Origin binding (above) closes impersonation and user-sig pseudonym harvesting, but leaves two agent-zk exposures open on its own: a hostile origin A that publishes victim tenant B's tenant/audience can still solicit an interaction — collecting a repeating nullifier, and a valid Semaphore proof of membership in B's registry — even though A can never present the resulting deed to B. Signed-material binding cannot close either: both are things A learns by asking a holder to sign or prove, not by replaying what the holder produced.

The fix is prevention, not further scoping. A tenant admin signs an offline vouch — human-readable prose, the same convention as admin-sig's binding — that a specific origin currently speaks for the tenant:

{origin} is claiming to act for Grantor tenant {tenant}.

Signing this authorises {origin} to ask people and agents for deeds belonging to the tenant below. Only sign it if you administer that tenant AND you control {origin} — anyone holding this signature can make holders believe {origin} speaks for you.

Tenant: {tenant} Audience: {aud} Origin: {origin} Epoch: {epoch} Expires (unix seconds): {exp}

grantor-origin-v1

The guard publishes that signature in its discovery document (origin_vouchrequired, not optional: an optional vouch is one a hostile origin simply omits, and a holder that tolerates its absence gains no protection at all). Before signing or proving anything, the holder recovers the vouch's signer against the origin it itself observed — never a value read out of the document, for the same reason origin joins the signed material above — and asks the chain isOriginVoucher(tenant, signer, epoch):

function isOriginVoucher(uint256 id, address signer, uint64 epoch)
    external view returns (bool)
{
    return isAdmin[id][signer] && epoch == originEpoch[id];
}

A tenant admin revokes a compromised or decommissioned origin's vouch with bumpOriginEpoch(id) — every outstanding vouch (all signed against the old epoch) stops verifying immediately, with no expiry to wait out.

What this closes today. agent-zk cross-origin linkability and membership disclosure — the two items Origin binding leaves open above — close by prevention: a holder that checks provenance before minting never authenticates to an origin that cannot show a vouch, so the interaction with a hostile origin never happens in the first place, and there is nothing left for A to solicit.

The provenance check is wired into every mint-side entry point, in all four languages: authenticate() and signInWithDeed() (user-sig) and ZkAgent.mintDeed (agent-zk); see Origin binding items 3-4 above for the ordering proof (each language asserts proving was never entered, not merely that an error came back). The agent-zk half of that check lives once, in grantor_sdk_core::agent::mint_agent_deed (crates/grantor-sdk-core/src/agent.rs), which every language's ZkAgent.mintDeed marshals into rather than duplicating the check per shim — a single shared implementation, rather than one copy per shim, is what keeps the check from silently drifting out of sync between languages.

The check remains holder-side, not RP-enforceable, and every residual limit documented in this section (a compromised admin key, a holder that skips its own check, a multi-origin RP) applies regardless.

What this does NOT close. A compromised tenant admin key can vouch for any origin it likes. This is not a new exposure — that same key can already add other admins and change the tenant's tier — and bumpOriginEpoch does not mitigate it: the compromised key simply signs a fresh vouch at the new epoch, immediately, exactly as a legitimate admin would (proven, not merely asserted, by origin_vouch_authenticates_then_revoked_by_epoch_bump in crates/grantor-verify/tests/sovereign_e2e.rs — the same key that gets bumped can re-vouch and pass again the moment it does). bumpOriginEpoch revokes an origin, not an admin.

Only as good as a holder that runs the check. This is a holder-side control. A holder that skips it — or a fork of the SDK that strips it out — gains nothing from a vouch existing; it will happily sign for whatever origin asks.

Part A's origin-scoped key derivation is not an independent backstop here. It scopes the per-app KEY, but the origin is an argument the deriving party supplies, and in a browser a hostile page is the deriving party — "the holder derives correctly" protects only a holder that is actually doing the deriving. The root seed itself is also origin-scoped, with the signed message naming the origin in prose; see Why the seed is origin-scoped under User login for what that buys and does not buy, and crates/grantor-verify/tests/usersig_root_seed_scope.rs for the regression pin.

There is no automatic, unskippable holder-side backstop behind the origin provenance check: a holder that skips it is relying on a human reading a wallet prompt.

The guard's own self-check is DX, not enforcement. It lets an RP catch its OWN misconfigured vouch at boot — one signed for the wrong origin, one that has quietly expired, one whose TTL no holder will accept — instead of discovering it from a stream of failed logins. It is anti-drift tooling for the operator, not a security control: nothing stops a bare RP that never calls it from being perfectly secure, because enforcement lives entirely on the holder side, above. Conversely, calling it buys an RP nothing if its holders don't check provenance either.

It comes in two halves, split by whether the check needs a chain read:

  • Offline, and unavoidable — building the discovery document refuses an expired vouch or one whose TTL exceeds the 90-day ceiling (ORIGIN_VOUCH_MAX_TTL_SECS). This is in the shared document builder, so every language gets it whether or not the operator remembers to run a self-check, and it fails at the point where the vouch would otherwise be advertised. Advertising a vouch that cannot work is worse than not advertising: the holder's resulting failure names nothing, this one names the field.
  • Chain-backed, and explicitverifyOwnOriginVouchAt (Rust: DeedGuard::verify_own_origin_vouch) additionally confirms the recovered signer genuinely is an admin for this tenant at this epoch, and returns the seconds remaining when the vouch expires within 14 days so an operator can warn on it. Call it once at boot. It reads through the uncached gate on purpose: a cached true would keep a bumpOriginEpoch-revoked origin passing for a TTL window, and a boot-time check has nothing to amortise.

The cost you accept. Checking isOriginVoucher before minting means login now depends on the holder having a working chain connection: the wallet's own provider being connected, and pointed at the right chain. A signature-only signer with no RPC access of its own — one wired to produce raw signatures and nothing else — cannot perform this lookup and cannot complete a login. This mirrors every other chain read in the sovereign tier (root recency, tenant status): the tier trades "no auth server" for "a working RPC connection is now part of the auth critical path," and origin provenance is one more read on that same path, not a new kind of dependency.

Nothing new is published on-chain. The vouch rides isAdmin — already a public mapping — plus one uint64 epoch counter per tenant (originEpoch). An on-chain origin registry (storing which domain a tenant claims, so anyone could look it up directly) was considered and rejected: it would publish a permanent, enumerable tenant → domain map, which — combined with the drawPeriod events the billing model already emits — would disclose who Grantor's customers are and when their subscriptions lapse. isOriginVoucher answers only "does this address currently vouch for this tenant at this epoch", never "what does this tenant claim as its origin", so nothing about a tenant's actual domain(s) is ever readable from chain state.

User login (user-sig)#

A human signs in with their wallet and gets a stable pseudonym scoped to the (tenant, origin) deployment — still with no Grantor service involved.

This holder path ships in TypeScript, Python, Go and RustuserRootBinding, deriveRootSeed, deriveAppKey, userSub, userBinding and mintUserDeed are exported from sdk/ts (wasm-bindgen), sdk/python and sdk/go (UniFFI), all asserted against the same sdk/conformance/vectors.json entries the Rust core defines, so the four bindings are proven identical rather than merely parallel implementations. The double-sign-and-compare determinism check (below) lives once in grantor-sdk-core and every binding calls into it — no language reimplements it. The Rust snippet below is the source of truth; see sdk/README.md for the equivalent TS/Python/Go calls.

use grantor_sdk_core::usersig::{derive_root_seed, derive_app_key, mint_user_deed};

// The wallet signs a human-readable message NAMING THIS ORIGIN
// (`user_root_binding`). `derive_root_seed` signs it TWICE and errors if the
// results differ — a wallet that signs non-deterministically would otherwise
// mint the user a brand-new identity on every login, silently. Failing
// loudly is the point.
//
// The seed is per-origin, so ONE POPUP PER ORIGIN — not one popup for the
// whole web. A single fixed message identical at every origin would let
// any site holding one `personal_sign` derive the holder's key at every
// other relying party. See "Why the seed is origin-scoped" below.
let seed = derive_root_seed(origin, |msg| wallet.personal_sign(msg))?;

// The key is derived locally for (tenant, origin), so one popup still covers
// every AUDIENCE this deployment serves. `origin` is the origin YOU actually
// obtained this challenge from — never a value taken out of a discovery
// document. The SAME canonical `origin` feeds every call here; deriving with
// one origin and minting with another would produce a deed whose pubkey no
// verifier can tie to its binding.
let app_key = derive_app_key(&seed, tenant_id, origin)?;
// `mint_user_deed` CANONICALISES `origin` before signing (`normalize_origin`)
// and is fallible for exactly that reason — a genuinely malformed origin (a
// real path, userinfo, …) is refused here, at mint time.
let token = mint_user_deed(&app_key, tenant_id, audience, origin, &challenge, exp)?;

aud is deliberately NOT in the derivation scope, so one (tenant, origin) yields ONE subject across every audience that deployment serves. That is what makes a role assignable: a role system must be able to say "this subject has this role", and it cannot if the same human is a different opaque sub at each of an operator's own APIs. aud keeps the job OAuth gives it — it stays in the signed user_binding, so a deed minted for one audience still cannot be replayed at another. aud restricts; sub identifies. The accepted cost: two audiences under one (tenant, origin) are correlatable by that operator. Across tenants and across origins nothing changed, and tenant now carries the whole cross-customer separation burden.

Why the seed is origin-scoped#

derive_root_seed signs a human-readable message naming the origin, mirroring admin-sig's binding, and the resulting seed is scoped to that origin. A single fixed message identical at every origin would instead let any site that obtained one personal_sign derive the holder's key at every relying party — full impersonation. Origin-scoping the seed closes that.

Do not describe user-sig itself as structurally closed. personal_sign lets the requesting page choose the message freely, so a hostile page can request the victim's origin-scoped message rather than its own, and the wallet — which cannot tell who is asking — will sign it. What stands there is the human reading a prompt that names an origin they are not on. That is the same phishing-resistance ceiling admin-sig deliberately accepts. Making it a hard guarantee needs a signing primitive the BROWSER binds to origin (passkeys / WebAuthn), not a better string. Pinned by residual_risk_a_hostile_page_may_request_another_origins_message in crates/grantor-verify/tests/usersig_root_seed_scope.rs.

What origin-scoping the seed buys, unconditionally: a seed captured at one origin is useless at any other, so compromise is contained to the origin the user actually signed at.

A signing primitive the browser binds to origin is built, as user-passkey. For holders who use a passkey, this residual is closed, and closed over-determined, not by one lucky check: WebAuthn binds the ceremony to an origin TWICE, independently, both written by the browser itself rather than application code — clientDataJSON.origin (the origin the ceremony actually ran on) and the credential's rpId, hashed into authenticatorData.rpIdHash (the origin the credential itself is scoped to, fixed at registration) — and the verifier checks both against its OWN configured cfg.origin, then recomputes sub from the credential public key, all cfg-anchored, none read from the wire. A hostile page cannot make either browser-written field lie about the page it is really running on, the way it can make personal_sign sign an arbitrary requested string. Mutation-pinned in crates/grantor-verify/tests/userpasskey_verify.rs (passkey_refuses_a_deed_whose_clientdata_origin_is_not_the_rp_origin): deleting the clientDataJSON.origin check does not make the mismatched-origin deed verify — it still fails, now via rpIdHash, so the mutation observes DeedError::BadProof rather than acceptance. That is what "over-determined" means concretely: no single field is the reason cross-origin deeds are refused, so removing one does not reopen the hole.

user-sig is unchanged and remains the compatibility path — it is not "fixed" by user-passkey existing, it retains exactly the residual documented above. Pick user-passkey when you can require a passkey; pick user-sig when you need to accept any wallet a human already has (compatibility). See Passkey login for the full mode.

What this proves — and what it does not. user-sig proves "I control a key", not "I control a wallet". Permissionless login is open by definition: anyone can generate a key. The wallet's role here is portability of identity across devices, not gatekeeping. Requiring membership is a separate, gated mode (allowlist / token-gate / DAO) that is not built yet.

There is no on-chain user revocation, because there is no on-chain user state to revoke — no registration, no commitment, no nullifier. An RP that wants to ban someone bans the sub on its own side. That is a property of permissionless login, not a gap.

What the chain sees#

The only on-chain read on this path is the tenant's billing status.

ScenariosubChain validation
Same wallet, 2 audiences, same tenant + origin1 sharedaud is not in the key scope, so a role can name the subjectOne check: that tenant's status
Same wallet, 2 origins, same tenant2 different, unlinkableOne check: that tenant's status
Same wallet, 2 apps, different tenants2 different, unlinkableTwo independent per-tenant checks
1 user or 10M usersIdentical; the chain never sees them

So user logins cost zero gas, leave zero on-chain footprint, and scale without touching the chain. Everything here is scoped by the on-chain tenant id rather than by any Grantor infrastructure, so a sub never breaks because of how or where an operator runs their own relying-party code.

Passkey login (user-passkey)#

A human logs in with a passkey (WebAuthn) instead of a wallet, and your app gets the same shape of stable, app-scoped pseudonym user-sig gives — except origin binding is enforced by the browser's own WebAuthn implementation, not by a human reading a signing prompt. Additive alongside user-sig: nothing about wallet login changes, and user-sig remains the compatibility path for a caller who only has a wallet. See Why the seed is origin-scoped above for the residual this closes and why it is closed for passkey holders specifically, not retroactively for user-sig.

The ceremony (browser, TypeScript)#

  1. Registration, once per (rpId, human). registerPasskey({ rpId, userName, userId }) calls navigator.credentials.create() with user verification required, extracts the raw P-256 credential public key, and returns { credentialId, credentialPubkeyHex } for your app to persist against that human.
  2. Your app issues a challenge — the same single-use challenge endpoint every deed mode uses (the deed guard).
  3. Login. signInWithPasskey({ credentialId, credentialPubkeyHex, tenant, aud, origin, challenge, exp }) calls navigator.credentials.get(), then assembles a Deed from the resulting WebAuthn assertion.
import { registerPasskey, signInWithPasskey } from "@grantor/sdk";

// once, at enrollment — persist credentialId/credentialPubkeyHex against this human
const { credentialId, credentialPubkeyHex } = await registerPasskey({
  rpId: "app.example.com",
  userName: "alice",
  userId: "alice-internal-id",
});

// each login
const deed = await signInWithPasskey({
  credentialId,
  credentialPubkeyHex,
  tenant: tenantId,
  aud: audience,
  origin,          // the origin YOU are actually running on
  challenge,        // from your app's own /challenge
  exp,
});

Your app then verifies deed exactly like any other — Verify a deed — and mints its own session however it already does.

The assembler — four languages#

One pure, cross-language primitive underlies all of this: mint_user_passkey_deed(credential_pubkey, authenticator_data, client_data_json, assertion_sig, tenant, aud, origin, challenge, exp) -> Deed, in grantor-sdk-core. It does no I/O and touches no live authenticator — it assembles a Deed from already-captured WebAuthn assertion bytes, which is what lets Python, Go and Rust mint (and their conformance suites test) a user-passkey deed with fixed fixture bytes, with no browser anywhere in the process:

use grantor_sdk_core::userpasskey::{mint_user_passkey_deed, passkey_sub};

// credential_pubkey/authenticator_data/assertion_sig are lowercase hex;
// client_data_json is base64url of the EXACT bytes the assertion signed —
// re-serializing the JSON would break the signature.
let deed = mint_user_passkey_deed(
    &credential_pubkey_hex, &authenticator_data_hex,
    &client_data_json_b64url, &assertion_sig_hex,
    tenant_id, audience, origin, &challenge, exp,
)?;

// the same pseudonym the verifier recomputes — useful for a holder that
// wants to know its own sub before minting
let sub = passkey_sub(tenant_id, origin, &credential_pubkey_hex);

Python and Go expose the same primitive as mint_user_passkey_deed/MintUserPasskeyDeed and passkey_sub/PasskeySub. TS ships it toomintUserPasskeyDeed/passkeySub are re-exported from @grantor/sdk's actual barrel (sdk/ts/src/index.js), the same low-level re-export mintUserDeed gets for user-sig — so all four languages carry the bare assembler, proven byte-identical against the same fixed WebAuthn fixture in sdk/conformance/vectors.json's userpasskey block: the "user-passkey mint (assembler)" row in sdk/capability_matrix.py probes the SAME symbol name (mintUserPasskeyDeed/mint_user_passkey_deed/MintUserPasskeyDeed) in every language, all four or it would not have shipped.

registerPasskey/signInWithPasskey (above) are TS/browser convenience glue built on top of that assembler — they own the navigator.credentials ceremony and then call mintUserPasskeyDeed for you — exactly the relationship mintUserDeed (raw) has to signInWithDeed (wrapper) for user-sig. Only the live ceremony itself is TS-only, by platform reality rather than a capability gap — there is no non-browser WebAuthn authenticator for any other language to wrap, the same shape just capability-matrix already accepts for authenticate()'s Rust exemption. The assembler underneath it is not TS-only; it is four-language, like everything else in this tier.

sub — one pseudonym per (tenant, origin, credential)#

sub = hex(SHA256(
  "grantor-sovereign-user-passkey-sub-v1|tenant:{tenant}|origin:{origin}|pubkey:{credential_pubkey_hex}"
))

Verifier-recomputed from its own cfg.tenant/cfg.origin and the presented credential_pubkey — never trusted off the wire, so a forged sub is structurally impossible, exactly like user-sig. Unlike user-sig, (tenant, origin) cannot be folded into key derivation (a passkey's private key never leaves the authenticator, so there is no seed a holder-side app could scope per-context) — folding them into the sub hash instead recovers the same property at the identifier level: one human = one sub per (tenant, origin). aud is not in scope either, for the same reason it is not in user-sig's: aud restricts where a deed is valid, sub identifies who holds it.

The binding contract — what is cryptographic vs. what is policy#

WebAuthn only lets your app control the challenge; the assertion signs authenticatorData ‖ SHA256(clientDataJSON), and clientDataJSON carries only type/challenge/origin/crossOrigin. So the binding splits in two:

  • origin and challenge are bound CRYPTOGRAPHICALLYorigin is written by the browser itself into clientDataJSON (a hostile page cannot forge it), and both origin and challenge are covered by the assertion signature. This is what closes the cross-origin residual user-sig cannot close on its own (see Why the seed is origin-scoped).
  • tenant, aud, exp are bound by VERIFIER POLICY plus the single-use challenge, not by anything inside the assertion. Your verifier issued this challenge for its own (tenant, aud, origin) policy and burns it exactly once; it rejects any deed whose tenant/aud do not match its own config. An attacker cannot retarget a captured assertion to a different verifier (that verifier issued a different challenge — clientDataJSON.challenge mismatch) or to a different aud/tenant at the same verifier (policy mismatch) — and sub is recomputed from (tenant, origin, pubkey), so a mutated envelope cannot keep a valid sub either.

This is the same trust model the sovereign challenge already provides for every mode; user-passkey leans on it for tenant/aud/exp, where user-sig additionally folds those into its signed user_binding string. signCount is deliberately ignored — the single-use challenge is the replay defense; a monotonic-counter check would add authenticator-state assumptions without improving on single-use.

What the verifier checks#

verify_user_passkey runs as the third arm of verify_deed_claims's mode dispatch, after the four checks shared with user-sig/agent-zk (mode, aud+tenant, challenge, expiry — see What the verifier checks below):

  1. Shape — a user-passkey deed must carry credential_pubkey/authenticator_data/ client_data_json/assertion_sig and none of root/proof/pubkey/signature (rejected symmetrically the other way too: a user-sig/agent-zk deed carrying any passkey-only field is Malformed).
  2. clientDataJSONtype == "webauthn.get"; challenge matches the challenge you issued; origin matches your own configured origin, checked by the browser rather than a human — this is what closes the cross-origin phishing residual user-sig cannot close on its own.
  3. authenticatorDatarpIdHash (bytes 0–32) equals SHA256(host(cfg.origin)); the flags byte has both UP (user present) and UV (user verification) set.
  4. Assertion signature — ES256 (P-256) verified over authenticatorData ‖ SHA256(clientDataJSON) with credential_pubkey.
  5. sub recompute — from cfg.tenant, cfg.origin and the presented credential_pubkey; must match exactly.

There is no step 7/8 membership or revocation check here, same as user-sig: a permissionless login has no membership root. Step 8's tenant-status (billing) check still applies — user-passkey costs your app the same zero gas, zero on-chain footprint as user-sig.

Smart-wallet login (user-1271)#

A human logs in with an EIP-1271 smart-contract wallet — a Safe, or any other contract that implements isValidSignature(bytes32,bytes) — instead of an EOA. Additive alongside user-sig/user-passkey, not a replacement: pick user-1271 when your users' wallets are multisig/smart-contract accounts; user-sig/user-passkey remain the maximally-private options for an EOA or a passkey holder.

Why this is NOT "EIP-1271 on the user-sig verify path." An earlier note (now corrected — see the launch runbook) described this as riding user-sig. It cannot: user-sig's wallet signature never reaches the verifier at all. The wallet only signs user_root_binding once, HOLDER-SIDE, to derive a root_seed; from there a brand-new local ECDSA keypair is derived via HKDF, and THAT local key signs the deed the verifier actually checks — pure local ECDSA math with no chain read. A smart wallet has no recoverable private key to derive a local keypair's seed from in the first place, and there is no reproducible raw signature to hash even if it did (a Safe's approval is aggregated on-chain, not a single deterministic personal_sign output a holder could double-sign-and-compare). So user-1271 is not a variant of user-sig's flow — it is a different verification path entirely, built on the SAME Erc1271Reader on-chain gate admin-sig's smart-wallet variant uses (below).

The pseudonymity tradeoff — read before picking this mode#

user-1271's sub is linkable to the wallet's on-chain address by anyone who knows that address. This is a real, deliberate difference from user-sig/user-passkey:

  • user-sig's sub is derived from a locally-held app-scoped key that never appears on-chain anywhere; user-passkey's sub is derived from a credential public key that lives only in an authenticator. Neither is discoverable by anyone who merely knows the human's wallet address — there is no on-chain artifact to correlate against.
  • user-1271's sub is hex(SHA256("...tenant:{t}|origin:{o}|wallet:{addr}")) — a deterministic hash of the wallet's PUBLIC, on-chain address. Anyone who already knows that address (which is inherently public — every EIP-1271 wallet is a deployed contract) can recompute the same hash for a guessed (tenant, origin) pair and confirm whether "this smart wallet" is "that sub". The pseudonym hides nothing from an observer who already has the one input smart-contract wallets cannot avoid publishing: their own address.

Pick user-1271 for its OTHER property, not privacy: social recovery. A smart-contract wallet's whole point is that no single stolen owner key is game-over — a Safe with a 2-of-3 threshold survives one compromised signer. user-sig/user-passkey give you the strongest pseudonymity this tier has; user-1271 trades that away for wallet-level key-loss/theft resilience. Use whichever tradeoff your users actually need — they are not ranked, they answer different questions.

The assembler — four languages#

Pure, cross-language, exactly like user-passkey's: mint_user_1271_deed(signer_addr, wallet_sig, tenant, aud, origin, challenge, exp) -> Deed in grantor-sdk-core::user1271 does no I/O and calls no chain — it assembles a Deed from a wallet CONTRACT address and whatever opaque signature bytes that contract's own scheme produced (a Safe threshold signature, an ERC-4337 account's own scheme, …). It does not interpret those bytes at all; only the verifier's on-chain isValidSignature call does that.

use grantor_sdk_core::user1271::{mint_user_1271_deed, user1271_sub};

// signer_addr_hex/wallet_sig_hex are whatever your wallet SDK (e.g. Safe's) already
// produced for you — obtaining them is app glue, not this SDK's job (see below).
let deed = mint_user_1271_deed(
    &signer_addr_hex, &wallet_sig_hex,
    tenant_id, audience, origin, &challenge, exp,
)?;

// the same pseudonym the verifier recomputes
let sub = user1271_sub(tenant_id, origin, &signer_addr_hex);

Ships as mintUser1271Deed/mint_user_1271_deed/MintUser1271Deed/mint_user_1271_deed (TS/Python/Go/Rust) — the "user-1271 mint" row in sdk/capability_matrix.py, re-exported at the TS barrel the same way mintUserPasskeyDeed is.

Obtaining the wallet's signature is app glue, not this SDK's job#

This SDK never talks to a Safe (or any other smart-contract wallet) directly — producing wallet_sig_hex is exactly the same kind of glue user-sig's wallet-signing callback is for an EOA, just one layer further out: a Safe's own SDK (@safe-global/protocol-kit) or a library like wagmi/viem's signMessage/readContract helpers gets you a signature the wallet contract will accept for a given hash. What this SDK controls, and pins exactly, is the message a genuine wallet must approve: the SAME user_binding string user-sig signs (grantor_sdk_core::usersig::user_binding(tenant, aud, origin, challenge, exp)), hashed with eip191_hash — your app-glue code obtains a signature/approval over that exact hash, then hands the raw bytes to mint_user_1271_deed. The verifier rebuilds the identical hash from ITS OWN policy and the challenge IT issued (never from the token) before asking the wallet contract, so an app cannot influence what the wallet is actually asked to approve.

What the verifier checks#

user-1271 cannot be checked by the pure verify_deed_claims at all — there is no local cryptography to run, only an on-chain read — so it is handled as a special case inside the gated verify_deed, BEFORE the pure function ever sees the token:

  1. Fail-closed origin policyenforce_configured_origin(cfg) runs first, same as verify_deed_claims's own step 0 and for the same reason: user-1271 reads cfg.origin in its own branch rather than calling verify_deed_claims, so without a copy of this check here a misconfigured insecure origin would refuse every other mode but silently keep authenticating user-1271 logins. See Fail-closed origin policy.
  2. Policy match + shapetenant/aud/challenge/exp checked against your own config exactly like every other mode; signer + signature required, every other mode's fields (root/proof/pubkey/credential_pubkey/authenticator_data/ client_data_json/assertion_sig) forbidden — Malformed otherwise, before any chain read.
  3. Rebuild the binding YOU issueduser_binding(cfg.tenant, cfg.audience, origin, expected_challenge, token.exp), hashed with eip191_hash — never read from the token.
  4. Ask the wallet contract — the ONE chain call this mode needs: gate.is_valid_signature(signer, hash, signature). Fail-closed by contract: an RPC error, a revert, or any non-magic return maps to refusal, never acceptance.
  5. sub recompute — from cfg.tenant, the normalized origin, and the presented signer address; must match exactly, so a forged sub is structurally impossible.
  6. Billinggate.tenant_is_active(cfg.tenant), exactly like user-sig/user-passkey/ agent-zk. user-1271 is a login mode, not an administrative one, so unlike admin-sig it stays gated on billing.

Proven against a REAL, live-deployed ERC1271WalletMock (crates/grantor-verify/tests/eip1271_e2e.rs), not only a fake Erc1271Reader — including a malicious wallet that always returns a non-magic value, and a real signature from the WRONG owner.

Dashboard login (admin-sig)#

Grantor's own control-plane dashboard used to be the one account-shaped thing left in the product: a SIWE message exchanged for a session cookie, checked separately against on-chain is_admin. It now authenticates the same way it tells you to — with a deed. There is no SIWE code left in the controlplane.

admin-sig is mechanically SIWE repackaged as a deed: the wallet signs a domain-separated, HUMAN-READABLE message (leading with the requesting aud, stating in plain words that signing grants administrative access, and warning against signing it anywhere else — the personal_sign equivalent of SIWE's wallet-rendered warning, since a compact string gives a wallet nothing to show a signer) that carries labelled tenant/challenge/exp fields and ends with a grantor-sovereign-admin-v1 discriminator — distinct from user-sig's grantor-sovereign-user-v1 prefix, so a signature solicited for one mode can never be replayed as the other. The verifier recovers the signer's address from the signature, and sub is that address, recomputed and compared rather than trusted from the wire — forging it is structurally impossible, exactly as user-sig recomputes sub from a public key. Unlike user-sig, admin-sig does not hide who signed: the whole point is to identify the address that will next be checked against on-chain is_admin. Administration is an identified context, not an anonymous one — don't describe this mode as anonymous.

It is not reachable through verify_deed. agent-zk and user-sig share that call; admin-sig has its own entry point, grantor_verify::sovereign::verify_admin_deed, which takes no chain-gate parameter at all — there is no handle to call even by mistake. verify_deed (and verify_deed_claims) reject mode: "admin-sig" outright. This is not merely a policy an integrator could opt out of: a normal relying party calling the normal entry point structurally cannot accept an admin deed, because the code path that would accept one doesn't exist there. Do not build a shared "verify any deed" wrapper over both entry points — that would recreate exactly the shared-surface risk this split exists to avoid.

Why it makes no chain call, and must not gain one. agent-zk and user-sig both check tenant billing status before accepting the deed. admin-sig deliberately checks nothing on-chain — not root recency (there is no membership tree to be recent against), and not tenant status either, even though that would be easy to bolt on. The reason is a lockout: if verify_admin_deed refused a lapsed tenant's admin, that admin would be locked out of the one page that lets them pay the bill and reactivate the tenant. Access to pay a bill must never require having paid. If you find yourself adding a billing check here later, stop — you are about to reintroduce that lockout.

A successful admin-sig verify proves control of a wallet address, nothing more — it authenticates, it does not authorize. The controlplane still runs its own on-chain is_admin(tenant, address) check before granting anything; the deed only replaced the authentication step SIWE used to perform, and touches no authorization logic.

Smart-wallet admin (Safe / EIP-1271)#

This is a library capability an RP can adopt for its own admin surface — it is NOT (yet) what Grantor's own dashboard/controlplane does. crates/grantor-controlplane/src/http/deed.rs still calls only the EOA verify_admin_deed, which rejects a signer-bearing (smart-wallet) deed as Malformed; wiring Grantor's own dashboard to also accept a Safe admin is a follow-up, not shipped. Everything below describes what the mint/verify pair does for whoever adopts it, not a claim about Grantor's own deployment.

A tenant's administrator does not have to be an EOA. mint_admin_deed_smartwallet (grantor_sdk_core::adminsig) is the counterpart to mint_admin_deed above for an admin whose wallet is a Safe or any other EIP-1271 contract: it takes the wallet CONTRACT's address and whatever opaque approval bytes that contract's own scheme produced, and mints the SAME admin-sig wire mode — sub is admin_sub(signer), the identical 0x-prefixed address format mint_admin_deed produces for a recovered EOA, so an adopting RP's own is_admin(tenant, sub) authorization check needs no separate code path for a smart-wallet admin.

Verified by its own entry point, verify_admin_deed_smartwallet (exported as verifyAdminSmartwalletAt/verify_admin_smartwallet_at/VerifyAdminSmartwalletAt on DeedVerifier in TS/Python/Go, crate::sovereign::verify_admin_deed_smartwallet in Rust) — mirroring the EOA path's isolation exactly: not reachable through verify_deed either, and it makes the SAME deliberate choice to skip a tenant-status check, for the same lockout reason (access to pay a lapsed tenant's bill must never itself require having paid). The one structural difference from the EOA entry point: this one DOES take a chain reader parameter (&impl Erc1271Reader) — there is no recoverable address to check locally, so asking the wallet contract's own isValidSignature is the entire check. Like verify_admin_deed, this authenticates only; the calling RP still runs its own is_admin(tenant, sub) check before granting anything — for Grantor's own dashboard, that would be the controlplane, but see the note above: it does not call this entry point yet.

What the verifier checks#

This section covers the pure verify_deed_claimsagent-zk, user-sig and user-passkey. user-1271 is deliberately NOT covered here — it has no local cryptography to check at all, so verify_deed (the gated wrapper around verify_deed_claims) special-cases it and runs its own, entirely on-chain checklist BEFORE ever calling this pure function; see Smart-wallet login § What the verifier checks above. admin-sig (both the EOA and the EIP-1271-smart-wallet variant) has its own, shorter checklists in Dashboard login above; neither is reachable through verify_deed at all.

verify_deed(&deed, &policy, &expected_challenge, now, &gate) runs, in order. Step 0 is a fact about the verifier's OWN configuration, checked before the presented deed is inspected at all; steps 1–4 are shared across modes; step 5 dispatches on mode.

  1. Fail-closed origin policycfg.origin must be HTTPS and routable, or cfg.allow_insecure_origin must be set. Checked FIRST, before the mode check, before the deed's own origin comparison (steps 2 and 6 below), and before either chain read (steps 7–8) — a misconfigured deployment refuses every token the same way, at zero RPC cost, rather than failing unpredictably partway through. See Fail-closed origin policy.
  2. Token version + modetoken.v/token.mode must be one this verifier supports (MODE_AGENT_ZK, MODE_USER_SIG or MODE_USER_PASSKEY).
  3. Audience + tenant match — bound against your DeedPolicy, never read back from the token.
  4. Challenge match — the exact challenge you issued (caller-supplied expected_challenge, never trusted from the token alone).
  5. Expiry / TTL ceilingexp is in the future and within max_ttl_secs of now.
  6. Per-mode shape + cryptography. Each mode requires exactly its own fields: agent-zk must carry root+proof and no pubkey/signature; user-sig must carry pubkey+signature and no root/proof; user-passkey must carry credential_pubkey+authenticator_data+ client_data_json+assertion_sig and none of the other three modes' fields. Field exclusivity is symmetric across all modes — a wrong-shape token is Malformed, never quietly accepted.
    • For user-sig: sub is recomputed from the presented public key and must match exactly — so a forged sub is structurally impossible, not merely rejected — and the ECDSA signature is checked over a binding rebuilt from your config (including origin — see Origin binding) and the challenge you issued. A tampered aud/origin/exp/tenant therefore yields a binding the signature was never made over. There is no step 7 for this mode: a permissionless login has no membership root to check.
    • For user-passkey: full checklist in Passkey login § What the verifier checks above — clientDataJSON type/challenge/origin, authenticatorData rpIdHash + UP/UV flags, ES256 assertion signature, then sub recomputed from cfg.tenant/cfg.origin/the presented credential_pubkey. Also no step 7: permissionless, no membership root.
    • For agent-zk: parse root / sub / proof (malformed fields fail before any cryptography), then:
  7. ZK proof validity (agent-zk) — the Semaphore proof cryptographically verifies against the token's own claimed root/sub and the binding hashes recomputed from your DeedPolicy + expected_challenge, at the pinned tree depth (depth_20). This is purely local math against values already in hand — it does NOT touch the tenant's on-chain registry; that's steps 7-8, next. Last of the pure claim checks.
  8. On-chain root recency = revocationgate.root_is_recent(tenant, root). A revoked agent's old root ages out of the registry's bounded recent-root window, so a stale proof stops verifying with no issuer needed to blocklist anything.
  9. On-chain tenant status = billinggate.tenant_is_active(tenant). Accepts Active OR Grace — the exact gate the issuer itself enforces before minting (GrantorRegistry.isActive), so a tenant coasting through its billing grace window keeps its agents authenticating here uninterrupted. Only once a tenant falls all the way to Inactive (no funded/drawn period, or grace expired) do its agents' otherwise-valid proofs fail here. This is the tier's entire revenue enforcement — no server-side metering required.

Ordering is load-bearing: a misconfigured cfg.origin fails at step 0 before the deed is even looked at; a token that's the wrong version, wrong audience/tenant, wrong/replayed challenge, expired, unparseable, or carrying a bad ZK proof fails at step 1-6 and costs zero RPC calls, because both chain reads (7-8) come last. Both chain reads are live RPC through AlloyGate; CachedGate only bounds how often you re-hit your RPC provider, not what gets enforced.

Minting your session — session_jwt (optional)#

Once verify_deed returns claims, minting your own session however you already do stays the first-class path — nothing here changes that, and your OIDC stack carries on unchanged either way. If you would rather not hand-roll a JWT, the same SDK can mint one: a standard ES256 token, signed with your own key (never one Grantor holds or sees), decodable by any JOSE library on the checking side. RFC 6979 deterministic ECDSA means the same inputs mint byte-identical tokens across all four languages, pinned by a conformance vector plus a tamper test.

It is an RP-side convenience layered on top of verification, not a session dependency — sugar, not infrastructure. 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, all four languages or it would not have shipped. See Verify a deed § Or one call for the excerpt, transcribed from the runnable MCP reference.

Operating it: rate-limit the challenge endpoint#

The one piece of operational hygiene this tier asks of you, because it is the one thing the library cannot do for itself.

The challenge endpoint is public and unauthenticated by design — an agent must be able to obtain a challenge before it holds any credential. So the RP owns the flood defence: a per-IP or per-subnet limit in whatever middleware you already run. MemoryChallengeStore sweeps expired entries on every issue, so it is bounded by challenges issued within one TTL window — but that bound is your request rate times the TTL, and both are the caller's to move. Shortening the TTL tightens it proportionally and costs nothing: it only has to outlast discover → challenge → mint, which is seconds.

Why the library doesn't just cap the map, when it does exactly that for the two (tenant, root) caches: eviction there is safe by construction — a cache is an optimisation over an eth_call, so dropping an entry costs a chain read and never a wrong answer. A challenge is not a cache. It is one half of a live login, and evicting it fails a legitimate in-flight authentication with ChallengeMismatch — indistinguishable to the holder from an attack, and triggered by the attacker's traffic rather than their own. A cap would trade a memory-pressure problem for an availability one and hand the attacker a cheaper lever than the one they started with.

Nothing here is a billing or authentication bypass: a challenge is single-use and TTL-bound whatever the volume, and every deed still faces the same checks.

Paying, and unpaying#

Billing is prepay-then-draw, and the money is custodial to nobody:

  • topUp(id, amount) moves USDC from you into the registry contract and credits your tenant's balance. It does not pay the operator.
  • drawPeriod(id) debits one period's fee from that balance and transfers it to an immutable treasury address, fixed at deployment and unchangeable afterwards.
  • withdrawBalance(id, amount, to) returns any undrawn balance to an address a tenant admin picks. Only periods you actually consumed are non-refundable.

The registry has no owner function that can move tenant balances — no withdraw, sweep or rescue — and invariant_usdc_equals_sum_of_balances asserts the contract's USDC holdings always equal the sum of tenant balances.

Why "deed"?#

A deed is an instrument: you prove entitlement by presenting it, and its force comes from a public register of record rather than from asking an authority in real time. The office that keeps such a register is called the Recorder of Deeds. That is this design line for line — a public on-chain registry, and a credential that proves entitlement against it with no server in the path. Law also has a precise term for a document carrying its own proof of execution, so no witness need appear to authenticate it: a self-proving instrument. That is exactly what this is.

But nobody grants it to you — the holder mints it#

True, and worth answering directly. The tenant's on-chain registration is the conveyance; the deed is the instrument proving that record. The holder mints the instrument, and it is the registry that makes it mean anything. A deed with no corresponding record is refused, which is what the NotAMember and TenantInactive outcomes are.

deed is deliberately a generic, lowercase, untrademarked noun — the same choice FIDO made with passkey. Grantor is the brand; a deed is the thing.

Running the suite#

just test-sovereign   # cargo test -p grantor-verify --features sovereign-chain

This spins up a local anvil node, deploys the registry, and runs the full happy-path + negative-path (revoked agent, unpaid tenant, replayed challenge) e2e — no Postgres, no issuer, nothing but the chain.

This page is also served as Markdown — agents should read that. The whole tree is indexed for machines in llms.txt.