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);

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 }) });
});

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 authorize 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. Membership is the authorization — no per-agent config on your server, and you learn a stable pseudonym per agent, not a wallet address.
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.
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.

Total
0
Shares
Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Post

Preparedness in Quality Manufacturing

Next Post

The True Cost of Manual Quality and How to Win Budget for a Digital Switch

Related Posts