Sponsored Content

DEV Community

Grantor
Grantor

Posted on

Auth for your MCP server, without running an authorization server

You built a remote MCP server. It's useful, so now strangers' agents want
to call it, and you need to answer the question every remote server hits:
who's allowed in?

The MCP spec's answer is OAuth 2.1: put an authorization server in front,
register clients, issue tokens. Which is correct, and heavy. An
authorization server is a stateful, security-critical service with a user
database — you run it or you rent it, and either way it's now load-bearing
infrastructure for what might be a weekend project. It also assumes your
callers can do an OAuth dance, and that you want accounts.

Here's the shape with no authorization server anywhere: the credential —
called a deed — certifies itself. An agent mints one locally by signing
a challenge you issued (or by proving fleet membership in zero knowledge —
more below). Your server verifies it with a library: local crypto plus one
eth_call to a public registry contract on Base, served by any RPC
provider. No token service, no client registration, no user table. Your
whole auth stack is three routes:

import { DeedVerifier, sessionJwt } from "@grantor/verify";
import { grantorExpress } from "@grantor/verify/express";
import { Registry } from "@grantor/verify/registry";

const verifier = new DeedVerifier(
  RPC_URL, Registry.canonical(), CHAIN_ID, TENANT_ID,
  AUDIENCE, ORIGIN, MAX_TTL_SECS, CACHE_TTL_SECS,
  false, Math.floor(Date.now() / 1000),
);

const g = grantorExpress({
  verifier, app,
  challengeEndpoint: "/auth/challenge",
  chainId: CHAIN_ID,
  modes: ["user-sig", "agent-zk"],
  vouchSignature: VOUCH_SIGNATURE, vouchEpoch: VOUCH_EPOCH, vouchExp: VOUCH_EXP,
});
app.get("/auth/challenge", g.challenge);
Enter fullscreen mode Exit fullscreen mode

That one call also auto-publishes GET /.well-known/grantor-deed — a
discovery document naming your tenant, chain, modes, and challenge
endpoint — and self-checks it at startup, so a misconfiguration fails your
boot, not your first user's login.

The second route exchanges a deed for a session, the way a token endpoint
would — an MCP client authenticates once, not per request:

app.post("/auth/token", async (req, res) => {
  const { deed, challenge } = req.body;
  const claims = await g.guard.verify(JSON.stringify(deed), challenge);
  // claims.sub is a verified, pseudonymous subject — recomputed by the
  // verifier, not read from the deed. Mint YOUR session from it:
  res.json({ session_jwt: sessionJwt(claims.sub, AUDIENCE, BigInt(TENANT_ID),
    SIGNING_KEY_PEM, BigInt(now), BigInt(TTL), { iss: ORIGIN }) });
});
Enter fullscreen mode Exit fullscreen mode

sessionJwt mints a plain ES256 JWT with your key — any JOSE library
verifies it without ever importing this SDK. The third route is your
existing MCP transport, gated on that session. That's the entire surface.

Three properties you don't usually get from a weekend auth setup:

  • Rejections teach the caller. Every 401 carries WWW-Authenticate: Grantor-Deed … plus discovery/learn fields pointing at your discovery document and a machine-readable onboarding manifest. The guard also serves RFC 9728 protected-resource metadata, so an MCP-spec OAuth client discovers what your server needs the standard way. A capable agent that gets rejected can read its way to authenticated — no human writes an integration ticket. (All of it opt-out with one flag if you want silent 401s.)
  • You can authenticate a whole fleet without an allowlist. With agent-zk in modes, any agent enrolled in your tenant's on-chain registry proves membership in zero knowledge — no per-agent config on your server, and you learn a stable pseudonym per agent, not a wallet address. Membership answers who may call; it does not answer what they may do. For consequential tools, pair it with a capability grant — a resource plus caveats, narrowed at every delegation hop and checked locally per request — so a valid subject doesn't silently gain reach as you add tools. npx -y @grantor/mcp wrap puts that in front of any stdio MCP server without code changes.
  • Billing is enforced where verification happens. The verifier checks the tenant's on-chain status during the same read, and fails closed if the chain is unreachable. Nobody can verify deeds against a lapsed tenant.
  • A session is a bearer token, and behaves like one. sessionJwt mints from verified claims with your key and ignores the deed's own expiry — so an already-issued session outlives a tenant lapse or a revoked grant until it expires. Deed verification is a library call plus one eth_call, not a round trip to an introspection service, so re-verifying per call on consequential routes costs you a chain read, not an architecture. Short sessions for cheap reads; re-verify where it matters. Full timing model: https://chaingrantor.com/docs/guide/revocation-latency.html

What it costs, honestly: you need a tenant on the registry (createTenant
plus USDC funding on Base — a few contract calls, no signup form, because
there is no server to sign up with) and a signed origin vouch for wherever
your server runs, which is what lets a well-behaved agent refuse to
authenticate to a hostile origin impersonating you. The whole thing is an
unaudited developer preview. The guard ships in TypeScript, Python, Go,
and Rust, so this isn't an Express-only story.

Full guide, transcribed from a runnable reference server:
https://chaingrantor.com/docs/guide/mcp-server — and #1 in this series
covers the other direction, gating what your own sub-agents may do
(including wrapping any third-party MCP server so enforcement is
structural, not voluntary):
https://dev.to/grantor/give-your-ai-sub-agent-a-budget-not-your-keys-2e7h

OAuth told us auth needs an authorization server. For agents, it needs an
authorization — the server part turns out to be optional.


Update: two corrections above, prompted by a good question in the comments.
The fleet bullet originally read "membership is the authorization," which is
true of identity and false of authorization; and the post didn't state that a
minted session outlives on-chain revocation. Full timing model, including what
is and isn't cached:
https://chaingrantor.com/docs/guide/revocation-latency.html

Top comments (2)

Collapse
 
seasonkoh profile image
WebAZ

The self-certifying credential plus short session handoff is a clean split. The boundary I would want to test is revocation after session issuance. If tenant membership, the origin vouch, or a delegated scope changes on-chain, does an already minted ES256 session remain valid until its TTL, or can the resource server fail it earlier without reintroducing a central introspection service?

For consequential MCP tools, I would also separate identity from authority over the transition. Fleet membership answers who may call, but the authorization still needs to constrain which tool, resource, amount or other capability envelope that subject may exercise. Otherwise a valid pseudonymous subject can become over-authorized as the server adds tools.

Have you considered publishing a revocation-latency model and a negative-test matrix for a replayed deed, a changed tool descriptor, a chain outage after session minting, and a scope downgrade during an active session?

Collapse
 
grantor profile image
Grantor

Thank you for reviewing it this carefully — it moved the product, not just
the post. Two of your three points landed, and both are now corrected in the
post itself. The first deserved more than a comment box, so I wrote it up:
chaingrantor.com/docs/guide/revoca...

Sessions. Yes — the session outlives the change. sessionJwt ignores the
deed's own expiry, so exp = now + ttl and no on-chain event shortens it.
But failing earlier needs no introspection service: the authority source is a
public chain your server already reads, so re-checking is local crypto plus
one eth_call — the same one the first verification made. The shape is
two-tier and the post only showed tier one: short sessions for cheap reads,
per-call re-verify on consequential tools. Latency table and the cache knob
are in the doc.

Identity vs. authority. "Membership is the authorization" is a line I
shouldn't have left unqualified — true of agent-zk as identity, false as
authorization. Grants with caveats, monotone narrowing at every hop, and a
local per-request check all exist; the post just didn't show them. That's a
writing failure, and the one that matters, since the post is what people
implement from.

Tool descriptors — a genuine gap. Resource matching is a
one-trailing-* prefix glob, so mcp:tool/* covers tools that didn't exist
when it was signed. Nothing binds a grant to a descriptor. Interim: grant
exact resources on surfaces that change. Pinning a schema hash into the grant
is the fix, and it's next.

The matrix mostly existed (replay, epoch-bump downgrade, revoked agent,
billing lapse, chain outage failing closed) — just nowhere readable. Now
listed by name, next to the rows that are honestly empty.