MCP AUTHORIZATION · OAUTH 2.1 · RFC 9728 · RFC 8707

Auth for your MCP server
in 5 minutes

You built tools. Claude, Cursor and VS Code want to call them — as a specific person, with their permission, and nothing else. The MCP spec says how. It does not say who signs the person in.

Make ZapQR that who. Your server publishes one JSON document and checks one signature. The agent finds ZapQR on its own, the person signs in with a passkey or a scan from their phone, and every tool call arrives with a verified identity. No passwords, no API keys, no user table.

$ the whole thing

# 1 · a server (Cloudflare Workers)
npx degit dasecure/zapqr-mcp-starter my-mcp
cd my-mcp && npm install
npx wrangler deploy
→ https://my-mcp.you.workers.dev

# 2 · tell ZapQR it exists
open https://auth.zapqr.ai/account/agents
  paste  https://my-mcp.you.workers.dev/mcp

# 3 · add it to Claude as a custom connector
  Claude finds ZapQR from your server's metadata,
  you sign in with your passkey, done.

Your server is a resource server. That's all it is.

The MCP authorization spec (2026‑07‑28 revision) builds on OAuth 2.1 and gives your MCP server exactly three jobs. It never signs anyone in, never stores a password, never sees a secret. Everything else is between the agent and the authorization server — and ZapQR is one you don't have to run.

RFC 9728

Publish metadata

Serve /.well-known/oauth-protected-resource naming https://auth.zapqr.ai as your authorization server.

RFC 6750 · 9728 §5

Challenge

Answer a request with no token with 401 and a WWW-Authenticate header pointing at that metadata. The agent takes it from there.

RFC 8707

Verify the audience

Check each token's signature against ZapQR's keys, and that its aud is your server. A token minted for anyone else's server is refused.

What actually happens

Blue steps are yours. Dark steps happen between the agent, ZapQR and the person — you never write code for them.

  1. The agent calls POST /mcp with no token

    Your server answers 401 with WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource".

  2. The agent reads your metadata and finds https://auth.zapqr.ai

    Then ZapQR's own discovery document. Then it registers itself — RFC 7591 dynamic registration, or by presenting a Client ID Metadata Document. Nothing for you to approve.

  3. The person signs in with ZapQR

    Passkey on this device, or a QR scanned with their phone, or a push to their phone. The consent screen reads "Connect Claude to your server (your-host)?" — one tap.

  4. ZapQR mints a JWT for your server

    iss auth.zapqr.ai · aud your URL · sub a stable account id · email + email_verified · scope openid mcp:tools. One hour; the agent refreshes it silently.

  5. Every tool call arrives with a verified identity

    You verify the signature against auth.zapqr.ai/jwks, the issuer, the expiry and the audience — the starter does this in one function — and your tool handler gets sub, email and the scopes on every call.

The whole integration

This is the gate in the starter's src/index.ts. Everything past it has a verified person behind it. The verification itself is jose's jwtVerify with issuer, audience and typ: 'at+jwt' pinned.

// src/index.ts — the gate
const verdict = await verifyBearer(bearerToken(request), cfg);
if (!verdict.ok) return challenge(cfg, verdict);   // 401 invalid_token · 403 insufficient_scope

// src/tools.ts — a tool that knows who is calling
server.registerTool('list_orders', { inputSchema: {} }, async (_args, extra) => {
  const { sub, email } = extra.authInfo.extra.identity;   // verified on THIS request
  const rows = await env.DB.prepare('select * from orders where customer = ?').bind(sub).all();
  return { content: [{ type: 'text', text: JSON.stringify(rows.results) }] };
});

Zero config

The audience defaults to <origin>/mcp of the request, so the URL wrangler prints is the URL you register. A custom domain works the moment you register it too.

Stateless

One transport per request, no session id — any Worker isolate answers any call, which is what the 2026‑07‑28 spec calls a stateless transport. Add a Durable Object only if a tool needs memory.

Tested without a network

npm test mints a token the way ZapQR mints it and drives initialize → tools/list → tools/call through the Worker. Nine tests, no mocks of the protocol.

What you are not getting

Three things people expect from "auth" that this deliberately isn't.

Not an API key

A token is bound to one person, one agent, one server, and expires in an hour. A long-lived machine credential is a different product, and a worse one for this job.

Not a session

Nothing is remembered between calls. The identity is re-verified on every request from the token the agent carries. Revoke the connection on the person's ZapQR account page and the next call fails.

Not your authorization logic

mcp:tools means "may use this server as me". Who may see which order is yours to decide, keyed on sub.

Which agents can connect

Any MCP client that implements the authorization flow from the 2025‑06‑18 spec onwards. That is the ones people use.

Claude

Settings → Connectors → Add custom connector → paste your /mcp URL.

Claude Code

claude mcp add --transport http my-mcp https://…/mcp

Cursor · VS Code

Add a remote MCP server with the URL. Both implement discovery.

Your own agent

If its MCP config only takes a URL and a headers map, it cannot run the flow — put an MCP-aware client in front of it.

Try a finished one first

ZapQR runs its own MCP server, built exactly the way this page describes. Connect it and you will see the whole flow — discovery, sign-in, consent, a tool call — before you write a line. It turns your ZapQR account into tools an agent may use as you.

connect this URL

https://auth.zapqr.ai/mcp

Claude: Settings → Connectors → Add custom connector → paste it. The consent screen reads "Connect Claude to your ZapQR account?" and lists two scopes:

  • zapqr:sessions — see and sign out your signed-in sessions
  • zapqr:connections — see and disconnect the sites and agents connected to your account

Disconnect it on your account page and the agent's next call is refused. That is the same lever your users will have over your server.

ToolDoesScope
whoamiWho the agent is acting as — email, verified or not.—
list_sessionsEvery browser and device signed in, marking the one that authorized this agent.sessions
sign_out_sessionEnd one session by id.sessions
sign_out_everywhere_elseEnd every session except the browser that authorized the agent. Needs confirm: true; pushes a security notification.sessions
list_connectionsThe sites and agents connected to your account and what each one receives, per resource.connections
disconnectRevoke one connection by id — including this agent's own.connections

Every tool re-checks its scope on every call. Nothing is cached between requests; revocation is immediate.

Passwordless for machines

An MCP server is the third kind of thing ZapQR signs a person into. Websites speak OpenID Connect. Hardware with a screen but no keyboard — kiosks, TVs, an $80 UNIHIKER — speaks RFC 8628 device flow. Agents speak the MCP authorization spec. Same account, same phone, same consent screen.

The four-minute video walks through the device-flow case on real hardware; the mechanism an agent uses is the same one with the QR step removed.

SurfaceProtocolRegisters at
WebsiteOpenID Connect/account · Your sites
Kiosk · TV · boardRFC 8628 device flowadmin · device client
MCP serverMCP authorization (OAuth 2.1)/account/agents
Agent (Claude, Cursor…)RFC 7591 · Client ID Metadata Docitself, automatically

Five minutes. Go.

Deploy the starter, register the URL it prints, add it to Claude. It is MIT licensed — replace the two sample tools with yours and keep the gate.

Questions, or a client that doesn't connect? vincent@dasecure.com