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.
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.
Blue steps are yours. Dark steps happen between the agent, ZapQR and the person — you never write code for them.
The agent calls POST /mcp with no token
Your server answers 401 with WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource".
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.
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.
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.
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.
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.
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.
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.
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 sessionszapqr:connections — see and disconnect the sites and agents connected to your accountDisconnect 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.
| Tool | Does | Scope |
|---|---|---|
| whoami | Who the agent is acting as — email, verified or not. | — |
| list_sessions | Every browser and device signed in, marking the one that authorized this agent. | sessions |
| sign_out_session | End one session by id. | sessions |
| sign_out_everywhere_else | End every session except the browser that authorized the agent. Needs confirm: true; pushes a security notification. | sessions |
| list_connections | The sites and agents connected to your account and what each one receives, per resource. | connections |
| disconnect | Revoke 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.
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.
| Surface | Protocol | Registers at |
|---|---|---|
| Website | OpenID Connect | /account · Your sites |
| Kiosk · TV · board | RFC 8628 device flow | admin · device client |
| MCP server | MCP authorization (OAuth 2.1) | /account/agents |
| Agent (Claude, Cursor…) | RFC 7591 · Client ID Metadata Doc | itself, automatically |
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