Errors#
The strings your app branches on when a deed fails to verify, plus the handful an operator's own boot-time or config-time call can raise. The first table's source of truth is crates/grantor-verify/src/guard.rs's Rejected enum — the same codes in every language. BadKey in the second table is a separate, unrelated call (session_jwt), not a Rejected variant — see its row for where it comes from.
Verifying a presented deed#
These are what DeedGuard/DeedVerifier returns from a normal request — see Verify a deed. code() is the stable string an app branches on.
| HTTP | Code | When it happens |
|---|---|---|
401 | InsecureOrigin | The verifier's own configured origin is not HTTPS or is a non-routable host, and allowInsecureOrigin was not set — a misconfigured deployment, not a bad deed. Checked before anything about the presented deed, on every mode including user-1271. Opt in only for local dev / a demo (DeedVerifier's trailing allowInsecureOrigin argument). |
401 | MissingDeed | The caller sent no X-Grantor-Deed header. |
401 | MissingChallenge | The caller sent no X-Grantor-Challenge header. |
401 | UnknownChallenge | The challenge is unissued, expired, or already spent — burned before verification, so this also fires for a flood of bogus deeds. |
401 | BadDeedEncoding | The deed header is not base64url JSON (a bare JSON object is tolerated). |
401 | UnsupportedMode | token.mode is not one this verifier accepts (user-sig/user-passkey/agent-zk reach verify_deed, user-1271 at the chain-gated layer; admin-sig has its own entry point). |
401 | WrongAudience | The deed was minted for a different aud/tenant than this DeedPolicy. |
401 | ChallengeMismatch | The deed is bound to a different challenge than the one presented. |
401 | Expired | Past its own exp, or exp exceeds the policy's max_ttl_secs. |
401 | Malformed | Wrong field shape for its claimed mode (e.g. agent-zk carrying pubkey/signature, or user-sig carrying root/proof). |
401 | BadProof | A signature or proof failed to verify: secp256k1 ECDSA (user-sig), the P-256 passkey assertion (user-passkey), the Semaphore ZK proof (agent-zk), or the wallet's on-chain isValidSignature (user-1271). |
401 | StaleRoot | agent-zk only — the membership root has aged out of the registry's recent-root window; possible revocation. |
401 | TenantInactive | Billing — the tenant is neither Active nor Grace on-chain. |
503 | Chain | An on-chain read (eth_call) failed. |
503 | QuorumDivergence | Multi-RPC quorum only — every configured provider answered, but they disagreed on an on-chain read. One provider may be lying or lagging. |
401 | BadDelegation | Capabilities — a delegation link's signature failed to recover its claimed iss, the chain widened at some link (a child link's grants were not a legal narrowing of its parent's), a link is expired, a link's to does not chain to the next link's iss (or the last link's to does not equal the leaf's proven identity), a link's parent_ref_hash does not bind to its predecessor, the leaf's asserted grants exceed the chain's effective authority, or the chain's root is not a currently enrolled agent key. |
401 | EpochRevoked | Capabilities — a delegation link's signed (epoch_label, epoch) no longer matches the tenant's live delegationEpoch[label] on-chain; the tenant admin called bumpEpoch, revoking every link signed against the old value. |
401 | BadDelegationProof | zk-chain only — the structure-hiding delegation deed's Groth16 proof failed to verify against the committed verifying key and the reconstructed public inputs: a malformed proof, a tampered public input (root / effective grants / challenge / tenant / epoch), or a forged chain. Distinct from BadProof, which covers the leaf's own signature/membership proof, not the delegation chain — and distinct from StaleRoot/TenantInactive/EpochRevoked, which are fail-closed ON-CHAIN refusals of an otherwise cryptographically valid proof. |
403 | CapabilityDenied | Capabilities — CapabilityGuard.authorize found no effective grant covering the request (wrong resource, wrong action, or a failed exp/nb caveat). Distinct from every other code in this table: the deed itself is valid and the caller is authenticated, they are just not authorized for this particular request. See crates/grantor-verify/src/capguard.rs's module doc for the full rule set (resource matching, the nb predicate language, and why max_uses is exposed but not enforced here). |
503 | LicenseExpired | Enterprise dedicated-registry only — the operator-signed license bound to this DeedVerifier's RegistryRef::Dedicated registry is past exp + grace_secs. Checked first, on every verify call, at the CALLER-supplied clock — a license that was still in grace at construction can still lapse mid-deployment. Never diagnose as an attack (BadProof) or an outage (Chain): the deed and the chain are both fine — renew the license and restart. |
503 | WrongChain | The RPC endpoint's own eth_chainId does not match the chain id this verifier was configured for — a misconfigured deployment (wrong RPC, or a copy-pasted devnet URL left in a prod config), permanent until fixed. Checked once per AlloyGate instance, before its first real chain read. |
Chain and QuorumDivergence are 503 rather than 401 deliberately: a trust-infrastructure problem 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 (or the quorum cannot agree), no deed is accepted.
CapabilityDenied is 403 rather than 401 for the same kind of reason, pointed the other way: every other code in this table means "this deed did not check out," and a 401 correctly tells the caller to re-authenticate. CapabilityDenied means the OPPOSITE — the deed checked out fine — so a 401 would be actively wrong (re-authenticating changes nothing; the deed was never the problem).
LicenseExpired and WrongChain join Chain/QuorumDivergence in that 503 bucket for the identical reason: neither is the caller's fault, and re-authenticating fixes nothing — an operator has to renew the license or fix the RPC config before ANY caller can succeed again.
401 funnel: header and body guidance#
When returning a 401, send a WWW-Authenticate header and add two fields to the JSON response body to guide the holder:
WWW-Authenticate: Grantor-Deed realm="https://rp.example", discovery="/.well-known/grantor-deed"
Response body gains two additive fields (the original error and error_description are unchanged and always present):
discovery: always present, the path where the holder fetches this RP's discovery document (e.g./.well-known/grantor-deed). This is how a naive holder that cannot parse theWWW-Authenticateheader can still find the endpoint.
learn: the URL where the holder learns about onboarding, wallet support, and how to authenticate. Included by default; the RP can opt out via a configuration parameter (funnelHints: falsein TS,funnel_hints=Falsein Python,DisableFunnelHints: truein Go). When disabled, this field is omitted entirely, so a pre-funnel consumer that does not expect it is unaffected.
The realm in the header and the paths in the body should match the RP's own configured origin, fully normalized. Renderers: Rejected::www_authenticate() / Rejected::body_json() (Rust, public methods); TS and Python expose the same renderers through their adapter wrappers; Go's equivalents are unexported internals of the Guard type. All four languages pin their output against the shared conformance vectors as examples.
Status class rule: 401 sends the header plus both body fields; 403 sends only the body fields (no header — that header means "please authenticate," and this caller already did); 503 sends neither (this is an operator problem, not a credential problem).
Ordering is load-bearing: on an enterprise dedicated-registry DeedVerifier, LicenseExpired is checked FIRST of all — before InsecureOrigin, before the presented deed is inspected at all — because a lapsed license means this deployment cannot authenticate anyone, independent of the deed or the origin. InsecureOrigin is checked next, before the presented deed is inspected (it is a fact about the verifier's own configuration, not the deed). After that, a token that's the wrong version, wrong audience/tenant, wrong/replayed challenge, expired, unparseable, or carrying a bad proof fails before either chain read, so most rejections cost zero RPC calls. WrongChain surfaces only once a chain read is actually attempted (the same tier as Chain), checked once per AlloyGate instance rather than on every call. See Sovereign tier § What the verifier checks for the exact order.
Grantor-Deed scheme grammar#
Grantor-Deed follows the RFC 7235 auth-scheme/auth-param shape every WWW-Authenticate challenge uses, ABNF-ish:
challenge = "Grantor-Deed" 1*SP auth-param *( "," OWS auth-param )
auth-param = token "=" quoted-string
token = "realm" / "discovery"
Both realm and discovery are quoted-strings, escaped per RFC 7230 (backslash first, then quote — see above). Grantor-Deed is deliberately NOT a scheme registered with IANA's HTTP Authentication Scheme Registry — it names no token endpoint (there is none, by design; see Sovereign tier) and exists purely as a self-describing label a Grantor-aware holder recognizes. This is exactly why the RFC 9728 bridge below exists: an off-the-shelf OAuth-shaped client has no reason to know an unregistered scheme name, so it needs a registered signal instead.
RFC 9728 bridge: dual challenge + protected-resource metadata#
A caller that already speaks Grantor-Deed needs nothing more — but an off-the-shelf OAuth-shaped client (one that has never heard of Grantor at all) recognizes neither the scheme name nor the discovery parameter. RFC 9728 (OAuth 2.0 Protected Resource Metadata) gives such a client a path it DOES already know how to follow: a Bearer challenge carrying a resource_metadata parameter, pointing at a well-known JSON document.
When funnel hints are on (the same opt-out as above — funnelHints/ funnel_hints/DisableFunnelHints), the 401's WWW-Authenticate header becomes dual — Grantor-Deed stays first, unchanged, with the RFC 9728 scheme appended as a bridge, never a replacement:
WWW-Authenticate: Grantor-Deed realm="https://rp.example", discovery="/.well-known/grantor-deed", Bearer resource_metadata="/.well-known/oauth-protected-resource"
The RP additionally serves an RFC 9728 protected-resource metadata document at /.well-known/oauth-protected-resource (OAUTH_PROTECTED_RESOURCE_PATH/OAuthProtectedResourcePath), mounted the same way — and behind the same opt-out — as the /.well-known/grantor-deed discovery document:
{
"resource": "https://rp.example",
"authorization_servers": [],
"grantor_deed_discovery": "/.well-known/grantor-deed",
"grantor_onboard": "https://chaingrantor.com/.well-known/grantor-onboard.json",
"grantor_note": "no authorization server exists by design; authenticate with a Grantor deed via grantor_deed_discovery"
}
authorization_servers is deliberately empty — there is no authorization server, by design, and this document is honest about that rather than pointing a conforming RFC 9728 client at a URL that would 404. The grantor_* extension fields are what a Grantor-aware client — or a curious human — reads instead: grantor_deed_discovery is the real discovery document's path, grantor_onboard mirrors the learn body field, and an optional grantor_session_exchange (an RP-configured path, omitted — not null — when not set) names where a holder exchanges a verified deed for a session, if the RP offers one. Renderers: Rejected::www_authenticate_dual() / protected_resource_metadata() (Rust); TS's protectedResourceMetadata and Python's protected_resource_metadata are the same free-function builders exported from their guard modules; Go's equivalent is Guard.ProtectedResourceMetadata, a handler factory (Go's adapter owns no router, so the caller mounts the returned http.HandlerFunc itself — see its doc for why, unlike the other three languages, opting out makes it answer 404 rather than making the route simply not exist). All four languages pin their output against the shared conformance vectors.
Boot-time / config-time (not caller-facing)#
These codes are not returned while verifying a caller's deed — they surface only from an RP's own setup or misconfiguration, before any caller is involved.
| Code | When it happens |
|---|---|
OriginVouchExpired | This guard's own origin vouch is past its exp. Checked before any chain read. Comes from DeedGuard::verify_own_origin_vouch / verifyOwnOriginVouchAt, the DX check an RP calls once at boot to catch its own misconfigured vouch before it causes a stream of failed logins. See Sovereign tier § Origin provenance. |
OriginVouchTtlExceeded | This guard's own vouch has an exp further out than the maximum accepted TTL (90 days). Checked before any chain read. |
BadOriginVouch | The vouch signature is malformed, or the chain says the recovered signer is not a current voucher for this tenant/epoch. |
BadKey | The session signing key passed to session_jwt/sessionJwt/SessionJwt failed to parse — PKCS#8 PEM expected. Not a deed-verification failure: it means the RP's own call is misconfigured. See Verify a deed § Or one call. |
BadConfig | Enterprise dedicated-registry — DeedVerifier/DeedVerifierJs construction (or resolveRegistry/resolve_registry) refused a RegistryRef::Dedicated whose license was already past exp + grace_secs at the clock construction used — the "restart after lapse fails" property. Message starts "license/registry: ". This is a DIFFERENT code from the caller-facing LicenseExpired/503 above — that one fires when a license lapses WHILE an already-running verifier keeps serving requests. A boot-time health check that only alerts on LicenseExpired will never see a deployment that lapsed before it could even start; alert on both BadConfig messages containing "license/registry:" at boot AND LicenseExpired at request time. |
See also#
- Verify a deed — the relying-party side.
- Wallet login and Agent tokens — the flows that produce the deeds these errors are about.
- Sovereign tier — the full reference, including exact verification order.
../llms-full.txt— the entire product in one file.