Revocation latency#
A deed is verified against a public chain, not against a token-introspection service. That buys you a system with no authorization server in it — and it raises a fair question that this page answers precisely: when something changes on-chain, how long does the old answer keep working?
Read Concepts first if you haven't. The short version of this page:
Every chain-backed check is bounded by your cache TTL plus chain observation lag. A session JWT you minted is bounded by nothing but its own TTL — it is an ordinary bearer token, and the deed layer's revocation semantics do not reach inside it.
That second sentence is the one that decides your design, so it comes first.
The session boundary#
sessionJwt / session_jwt mints your ES256 token from already-verified claims. It deliberately ignores the deed's own exp: the session's expiry is now + ttl_secs and nothing else. A tenant that lapses, an origin epoch that is bumped, a delegation epoch that is revoked, an agent key that is revoked — none of them shorten a session that has already been issued.
This is not a gap to be patched; it is what "your OIDC stack carries on unchanged" costs. A session is a bearer token, and bearer tokens expire.
What it does not cost you is a central introspection service. Because the authority source is a public chain your resource server already reads directly, re-checking is local crypto plus one eth_call — the same call the first verification made. So the two-tier shape is:
| Call shape | What gates it | Revocation latency |
|---|---|---|
| Cheap, idempotent reads | session JWT | the session TTL |
| Consequential tools (writes, spend, deletion) | re-verify per call — verify_deed_with_capability + CapabilityGuard::authorize | cache TTL + chain lag |
The reference MCP server uses SESSION_JWT_TTL_SECS = 300. If you gate a consequential tool on a 300-second session, you have accepted up to five minutes of stale authority on that tool. Either shorten the TTL or re-verify on that path. Pick deliberately; the default is not a recommendation for every route.
What each vector costs#
| Change on-chain | Mechanism | Cached? | Latency bound |
|---|---|---|---|
| Replayed deed | challenge burned single-use, before parsing | n/a | immediate — the second use fails |
| Tenant lapses (billing) | status() → tenant_is_active | yes | cache TTL + chain lag |
| Agent removed from the fleet | root_is_recent (root changes on revoke) | yes | cache TTL + chain lag |
| Delegated scope revoked | bumpEpoch(label) → epoch check | yes | cache TTL + chain lag |
| Root agent key revoked | is_agent_key | yes | cache TTL + chain lag |
| Origin vouch revoked | bumpOriginEpoch → isOriginVoucher | no | next read |
| Smart-wallet policy changed | ERC-1271 isValidSignature | no | next read |
| Anything, once a session exists | — | — | session TTL (see above) |
CachedGate's ttl_secs is that knob, and its doc comment names it exactly: "the revocation/billing latency bound." The reference server sets CACHE_TTL_SECS = 30. The cache is bounded at 4096 entries per map, and it never caches an error and never serves a stale value in place of one — a failed read propagates.
Two reads are deliberately never cached. A cached true for an origin vouch would keep a bumpOriginEpoch-revoked origin passing for a whole TTL window, and the vouch is checked once at boot rather than per request, so there is nothing to amortise. A cached true for a smart wallet would outlive a Safe changing its owner set or threshold.
Chain observation lag#
"Cache TTL + chain lag" has a second term, and it is not zero:
- Finality. A revocation transaction is not observable until it is included and your RPC provider will serve it.
- Replica lag. A load-balanced RPC endpoint can serve a read from a replica that is behind the tip. Two consecutive reads can disagree. This is a real, observed failure mode, not a theoretical one — it is why the onboarding tooling retries reads after a write instead of asserting once.
If you need a hard bound rather than a typical one, treat chain lag as provider-dependent and measure it against your own endpoint.
Time-derived, not transaction-driven#
One property surprises people watching for events: a billing lapse happens with no transaction at all. status() is derived from periodEnd, graceWindow and block.timestamp — never stored. A tenant goes Active → Grace → Inactive because time passed, so there is no TenantDeactivated event to subscribe to. The next uncached read observes it; nothing announces it.
graceWindow is fixed at deployment (the shipped deploy scripts default to 3 days) and is readable on-chain. A lapsed tenant keeps authenticating through the whole grace window by design — isActive is true for Active || Grace — so your effective billing-revocation latency is graceWindow + cache TTL + chain lag, not just the cache TTL.
Ceilings the SDK enforces for you#
| Value | Ceiling | Where |
|---|---|---|
| Deed TTL | 86 400 s (24 h) | MAX_TTL_SECS_CEILING, refused at discovery-document build |
| Origin vouch TTL | 90 days, warning inside 14 days | ORIGIN_VOUCH_MAX_TTL_SECS |
| Challenge lifetime | 300 s default, single-use | MemoryChallengeStore |
The vouch expiry and ceiling are checked in the shared document builder, so every language refuses to advertise an unusable vouch whether or not the operator runs a self-check.
What is proven, and what is not#
These are live tests, not intentions:
| Property | Test |
|---|---|
| Replayed deed rejected | replayed_challenge_is_rejected |
| Scope downgrade mid-session | bumped_epoch_after_signing_is_epoch_revoked |
| One tier revoked, another survives | pro_tier_epoch_bump_revokes_the_tier_while_the_basic_license_still_holds |
| Revoked agent rejected | revoked_agent_is_rejected, revoked_root_agent_key_is_rejected |
| Billing lapse rejected | inactive_tenant_is_rejected_proving_billing_without_infra |
| Chain outage fails closed | fail-closed legs in the TypeScript, Python and Go e2e suites |
| Forged subject rejected | forged_sub_does_not_survive_since_it_is_recomputed |
| Origin revocation by epoch bump | origin_vouch_authenticates_then_revoked_by_epoch_bump |
Two things are not covered, stated plainly rather than left for you to discover:
- A changed tool descriptor.
resmatching is a one-trailing-*prefix glob, so a grant ofmcp:tool/*covers tools that did not exist when it was signed. Nothing binds a grant to a tool's schema. If your server grows tools over time, grant exact resources rather than a prefix, or re-issue grants when the surface changes. Pinning a descriptor hash into the grant is the structural fix and is not built. max_usesis not counted. It rides on every grant andCapabilityGuardinspects it, but the guard holds no counter and never will — durable counting is per-deployment state, like the challenge store. The matched grant exposes it so you can enforce it in your own database or Redis.
Choosing your numbers#
- Start from the worst thing a caller can do with a stale answer. If that is "read a public document," a long session is fine. If it is "spend money," re-verify per call.
- Set
CACHE_TTL_SECSto the revocation latency you can defend, not the one that minimises RPC spend. 30 seconds is the reference value. - Keep session TTL at or below the latency you promised for the most consequential route it gates.
- Grant exact resources, not prefixes, on surfaces that change.
- Remember
graceWindowwhen you reason about billing revocation.
See also#
- Capabilities — bounded, delegated agent authority: the layer that answers "which tool," not just "who"
- Verify a deed — the relying-party side
- Errors — every rejection code, including the fail-closed ones