MCP Security II — OAuth 2.1, Resource Indicators, Incremental Scopes
Remote MCP servers need authorization, and API keys don't cut it. The 2025-11-25 spec builds on OAuth 2.1 with PKCE, resource indicators (RFC 8707), and protected-resource metadata (RFC 9728). SEP-835 adds incremental scope consent: when a token is too weak, the server answers 403 with a WWW-Authenticate header and the client asks the user for more. You'll implement that step-up flow as a state machine and watch every hop.
Learning Objectives
- Distinguish resource server from authorization server responsibilities.
- Walk the PKCE-protected OAuth 2.1 authorization code flow.
- Use the `resource` parameter (RFC 8707) to pin tokens to one server, and protected-resource metadata (RFC 9728) for discovery.
- Implement step-up authorization: the server answers 403 with WWW-Authenticate asking for a higher scope, and the client re-prompts the user and retries.
The Problem
Early MCP (pre-2025) shipped remote servers with ad-hoc API keys or no auth at all. The 2025-11-25 spec closes that gap with a full OAuth 2.1 profile.
Three problems it has to solve:
- Ordinary remote servers. A user installs a remote MCP server that reaches their Notion, GitHub, or Gmail. OAuth 2.1 with PKCE is the right shape for that.
- Scope escalation. A notes server granted
notes:readmay later neednotes:writefor one specific action. Redoing the whole flow from scratch is clumsy, so step-up (SEP-835) asks only for the additional scope. - Confused deputy. The client holds a token meant for Server A. If A is malicious, it can try to present that token to Server B. Resource indicators (RFC 8707) pin the token to its intended audience.
OAuth 2.1 itself is not new. MCP's profile is: authorization code with PKCE is the only flow on the table (no implicit, no client credentials by default), a resource indicator goes on each token request, and servers publish protected-resource metadata so clients know where to go.
The Concept
Roles
- Client. The MCP client (Claude Desktop, Cursor, and so on).
- Resource server. The MCP server (notes, GitHub, Postgres, whatever you're exposing).
- Authorization server. Issues tokens. It can be the same service as the resource server or a separate IdP (Auth0, Keycloak, Cognito).
In MCP's profile the resource and authorization servers can share a host, but they should be told apart by URL.
Authorization code + PKCE
The flow:
- Client generates a random
code_verifierand derivescode_challengefrom it (SHA256). - Client redirects the user to
/authorize?response_type=code&client_id=...&redirect_uri=...&scope=notes:read&code_challenge=...&resource=https://notes.example.com. - User consents. Authorization server redirects to
redirect_uri?code=.... - Client POSTs to
/token?grant_type=authorization_code&code=...&code_verifier=...&resource=.... - Authorization server hashes the verifier, compares it to the stored challenge, and issues an access token.
- Client sends
Authorization: Bearer ...on each request to the resource server.
PKCE stops authorization-code interception: a stolen code is useless without the verifier. Resource indicators stop the token from being valid anywhere else.
Protected-resource metadata (RFC 9728)
The resource server publishes a .well-known/oauth-protected-resource document:
{
"resource": "https://notes.example.com",
"authorization_servers": ["https://auth.example.com"],
"scopes_supported": ["notes:read", "notes:write", "notes:delete"]
}The client discovers the authorization server from the resource server, so the only thing it needs to be configured with is the resource URL.
Resource indicators (RFC 8707)
The resource parameter in the token request pins the token's intended audience. The issued token carries aud: "https://notes.example.com". Another MCP server that receives this token checks aud and rejects it.
Scope model
Scopes are space-separated strings. Common MCP conventions:
notes:read,notes:write,notes:deleteadmin:*for admin capabilities (use sparingly)profile:readfor identity
Pick scopes by least privilege. Ask for what you need now and step up when you need more.
Step-up authorization (SEP-835)
The user grants notes:read, then asks the agent to delete a note. The server responds:
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
scope="notes:delete", resource="https://notes.example.com"
The client sees insufficient_scope, shows the user a consent dialog for the extra scope, runs a short OAuth flow for it, and retries the request with the new token.
Token audience validation
On each request the server checks token.aud == self.resource_url. A mismatch gets a 401, which is what stops a token from being reused across servers.
Short-lived tokens and rotation
Access tokens should be short-lived (1 hour is the default here). Refresh tokens rotate on each refresh, and the client handles silent refresh in the background.
No token passthrough
Sampling servers (Phase 13 · 11) must not pass the client's token on to other services. The sampling request is the boundary.
Confused deputy prevention
The token binds to aud and the client binds to client_id, and requests are validated against both. The spec explicitly bans the old pass-the-token pattern that was common in remote tool ecosystems before MCP.
Client ID discovery
An MCP client publishes its metadata at a fixed URL. Authorization servers can fetch that document to learn the client's redirect URIs and contact info, which removes manual client registration.
Gateways and OAuth
Phase 13 · 17 shows how an enterprise gateway handles OAuth. The gateway holds credentials for the upstream servers, the client gets gateway-issued tokens, and upstream tokens never leave the gateway. That flips the trust model: the user authenticates with the gateway once, and the gateway handles N server authorizations.
Use It
code/main.py simulates the OAuth 2.1 step-up flow as an in-memory state machine. It implements:
- PKCE code-verifier and challenge generation.
- Authorization code flow with a resource indicator.
- Token validation with an audience check, plus expiry.
- Step-up on
insufficient_scope.
It has no HTTP server and no metadata endpoint, so the protected-resource document above is something you build in the exercises. The demo asks for list, then create, then delete, so you see two step-ups in a row, and it ends by replaying the notes token against a different server to show the audience check rejecting it. Phase 13 · 17's gateway lesson wires this to a real transport.
Ship It
This lesson produces outputs/skill-oauth-scope-planner.md. Give it a remote MCP server and its tools, and it designs the scope set, the pinning rules, and the step-up policy.
I'd run main.py before reading the RFCs. The demo asks for list, create, and delete in turn, and the printed trace shows the client getting a 403, extracting the scope from the WWW-Authenticate header, and running the whole authorize and exchange sequence again. Watch what repeats. Step-up here is a full second pass through PKCE, not a patch on the old token.
Notice how the client asks for the new scope. It takes tok.scopes | {required} and requests the union, so the replacement token still covers everything the user already granted. If you only asked for notes:write, the new token would drop notes:read, and the next list call would hit another 403.
The code is also blunter than a real deployment, and I'd keep that in mind. authorize issues a code without ever asking anyone for consent, the client pulls the scope out of the header with a string split, and nothing handles refresh tokens. The audience check is the piece that carries over unchanged: one comparison of token.resource against the server's own URL, and the last demo step shows it blocking the notes token on another host.
Exercises
-
Run
code/main.py. Trace the two-scope step-up flow. Note which hops repeat on step-up. -
Add refresh-token rotation: every refresh issues a new refresh token and invalidates the old one. Simulate a stolen refresh token being used after rotation and confirm it fails.
-
Implement the protected-resource metadata endpoint as a real HTTP response using stdlib http.server. Mirror the /mcp endpoint from Lesson 09.
-
Design a scope hierarchy for a GitHub MCP server: read repo, write PR, approve PR, merge PR, admin. Use step-up between each level.
-
Read RFC 8707 and RFC 9728. Identify the one field in 9728 that MCP uses differently from the RFC's example. (Hint: it concerns
scopes_supported.)
Key Terms
| Term | What people say | What it actually means |
|---|---|---|
| OAuth 2.1 | "Modern OAuth" | Consolidated RFC that mandates PKCE and forbids implicit flow |
| PKCE | "Proof-of-possession" | Code verifier + challenge defeating authorization-code interception |
| Resource indicator | "Token audience" | RFC 8707 resource parameter pinning token to one server |
| Protected-resource metadata | "Discovery doc" | RFC 9728 .well-known/oauth-protected-resource |
| Step-up authorization | "Incremental consent" | SEP-835 flow for adding scopes on demand |
insufficient_scope | "403 with WWW-Authenticate" | Server signal to re-consent for a larger scope |
| Confused deputy | "Token reuse across services" | Attack where a trusted holder forwards a token inappropriately |
| Short-lived token | "Access token TTL" | Bearer that expires quickly; refresh token renews |
| Scope hierarchy | "Least privilege stack" | Graduated scope set with step-up between levels |
| Client ID metadata | "Client discovery doc" | URL at which the client publishes its own OAuth metadata |
Further Reading
- MCP: Authorization spec: canonical MCP OAuth profile
- den.dev: MCP November authorization spec: walkthrough of the 2025-11-25 changes
- RFC 8707: Resource indicators for OAuth 2.0: the audience-pinning RFC
- RFC 9728: OAuth 2.0 protected resource metadata: the discovery-document RFC
- Aembit: MCP OAuth 2.1, PKCE and the future of AI authorization: practical step-up-flow walk-through
Code
- main.py (python, 173 lines)