TAI BUI
Back to curriculum
BuildPython

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.

TIME: ~75 minutesPREREQUISITES: Phase 13 · 09 (transports), Phase 13 · 15 (security I)

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:read may later need notes:write for 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.

Warm-up0/3
1. A remote MCP server over HTTP is configured with one static API key pasted into every client's config. What is the main weakness?Warm-up
Three client boxes, each with a config line API_KEY equals K, all connected to one remote MCP server over HTTP.
K stands for the same key text in every config. Typed schematic, not captured output.
2. A website lets you sign in with your Google account without ever seeing your Google password. What does the website receive instead?Warm-up
You, Google and a website in a row. You sign in at Google, and an arrow runs from Google to the website with a dashed box asking what the website receives.
The website never sees your password. Typed schematic, not captured output.
3. An HTTP API replies with status 403 Forbidden. What does that generally mean?Warm-up
A client box and an HTTP API box. A request arrow goes right and a reply arrow comes back labelled status 403 Forbidden.

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:

  1. Client generates a random code_verifier and derives code_challenge from it (SHA256).
  2. Client redirects the user to /authorize?response_type=code&client_id=...&redirect_uri=...&scope=notes:read&code_challenge=...&resource=https://notes.example.com.
  3. User consents. Authorization server redirects to redirect_uri?code=....
  4. Client POSTs to /token?grant_type=authorization_code&code=...&code_verifier=...&resource=....
  5. Authorization server hashes the verifier, compares it to the stored challenge, and issues an access token.
  6. 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:

json
{
  "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:delete
  • admin:* for admin capabilities (use sparingly)
  • profile:read for 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.

Check: The Concept0/27
1. What is OAuth 2.1?Define
One row of the lesson's Key Terms table. The term is OAuth 2.1, what people say is Modern OAuth, and the cell for what it actually means is a dashed box with a question mark.
Fill in the last cell from the lesson. Table layout is typed, not captured.
2. Which attack does PKCE prevent?Define
A client, an auth server and an MCP server, with the five hops of the authorization code flow listed in order, and a dashed box asking which attack PKCE prevents.
Typed schematic, not captured output.
3. What does the `resource` parameter (RFC 8707) cause the issued token to carry?Define
A terminal-style box showing a POST to /token with grant_type, code, code_verifier and resource set to https://notes.example.com, with an arrow to a dashed box asking what the issued token carries.
Typed schematic, not captured output.
4. What does the resource server publish at `.well-known/oauth-protected-resource`?Define
A client box sending a GET to a resource server at notes.example.com, with the path .well-known/oauth-protected-resource below and a dashed box asking what the response body is.
Typed schematic, not captured output.
5. What is step-up authorization (SEP-835)?Define
Two boxes: the user has granted notes:read, and the agent is asked to delete a note. A dashed box asks what the user is asked to approve.
Scope names as in the lesson. Typed schematic, not captured output.
6. What does the `insufficient_scope` error tell the client?Define
A client sends a tool call to an MCP server and receives a reply labelled error insufficient_scope, with a dashed box asking what this tells the client.
Typed schematic, not captured output.
7. What is a confused deputy attack in the MCP token setting?Define
A client holding a token for Server A, with Server A at notes.example.com and Server B at github.example.com. A solid arrow runs from the client to A and none to B. A dashed box asks for the attack.
Hostnames from main.py's demo. Typed schematic, not captured output.
8. How long should access tokens live, and what happens to refresh tokens?Define
An access token card and a refresh token card, each with a dashed box for an unknown lifetime and when it is used.
Typed schematic, not captured output.
9. What is a scope hierarchy?Define
Five scope strings in boxes: notes:read, notes:write, notes:delete, profile:read and admin:*, with a dashed box asking what a scope hierarchy is.
Typed schematic, not captured output.
10. What is client ID metadata?Define
A client with client_id claude-desktop sends an authorize request to an auth server, with a dashed box asking what client ID metadata is.
client_id value from main.py's demo. Typed schematic, not captured output.
11. Can an MCP server and its authorization server be the same service?Define
Two boxes, an MCP server labelled resource server and an authorization server, with a dashed box asking whether they are the same service.
Typed schematic, not captured output.
12. What must a sampling server never do with the client's token?Define
A client holding a token, a sampling server it sends a sampling request to, and another service box. A dashed box asks what the sampling server must never do with the client's token.
Typed schematic, not captured output.
13. How do the resource server and the authorization server differ in MCP's OAuth profile?Explain
A client box and two server boxes, notes.example.com and auth.example.com, each with the role left as a question mark.
Typed schematic, not captured output.
14. Why does the token request send the `code_verifier`, which the authorization server hashes and compares with the stored challenge?Explain
A client and an auth server. The client makes a random code_verifier and derives code_challenge as SHA256 of it, sends the challenge to /authorize, and later sends code and code_verifier to /token.
Steps 1, 2 and 4 of the lesson's flow. Typed schematic, not captured output.
15. Why does an MCP client need to be configured with only the resource URL?Explain
A terminal-style box showing a client configured with only resource equals https://notes.example.com, followed by a user clicking connect and a dashed box asking where to authorize.
Typed schematic, not captured output.
16. What does a resource server check on every request to stop cross-server token reuse?Explain
A client sends an Authorization Bearer request to an MCP server at notes.example.com, with a dashed box asking what the server checks on every request.
Typed schematic, not captured output.
17. Why use separate scopes such as `notes:read`, `notes:write` and `notes:delete`, with `admin:*` used sparingly?Explain
A table from main.py mapping tool calls to required scopes: list and read need notes:read, create needs notes:write, delete needs notes:delete. A dashed box asks why scopes are separate.
From ResourceServer.scope_requirements in main.py.
18. Ten internal MCP servers sit behind an enterprise gateway. What changes for the user compared with connecting to each server directly?Explain
A user reaching ten numbered MCP servers directly, and the same user reaching the ten servers through a box labelled gateway, with a dashed box asking what changes for the user.
Typed schematic, not captured output.
19. A client holding only `notes:read` calls an action that needs `notes:delete`. What does the server return under SEP-835?Apply
A client token with scopes notes:read calling a server where delete needs notes:delete, with a dashed box asking what the server returns.
Scope requirement from main.py. Typed schematic, not captured output.
20. A user already approved `notes:read` and `notes:write`. The agent now needs `notes:delete`, so the client runs step-up requesting only `notes:delete`. What is the likely result?Apply
Token 1 with scopes notes:read and notes:write, an arrow to a step-up request for notes:delete only, then an arrow to a dashed box asking what token 2's scopes are.
Typed schematic, not captured output.
21. A notes server publishes metadata with `resource` set to `https://notes.example.com` and `authorization_servers` set to `["https://auth.example.com"]`. Where does the client send the user to authorize?Apply
A JSON metadata document with resource set to https://notes.example.com and authorization_servers listing https://auth.example.com, and a dashed box asking where the client sends the user.
22. Malicious Server A forwards the token it received to Server B, and B performs the action. Which control is missing at B?Analyze
A client sends a token to malicious Server A, which sends the same token on to Server B, where B performs the action. A dashed box asks which control is missing at B.
Typed schematic, not captured output.
23. A server answers a too-weak token with a bare 403 and no `WWW-Authenticate` header. What can the client no longer do?Analyze
A response box reading HTTP/1.1 403 Forbidden with no WWW-Authenticate header, a client box, and a dashed box asking what the client can no longer do.
Typed schematic, not captured output.
24. A sampling server forwards the client's token to a downstream API to skip a second consent prompt. What is wrong with that?Analyze
A client with a token sends a sampling request to a sampling server, which sends the client's token on to a downstream API, with a stated reason of skipping a second consent prompt. A dashed box asks what is wrong with that.
Typed schematic, not captured output.
25. Your team wants an agent to triage tickets. It only reads tickets today, but may later close them. Which design fits the scope guidance?Design
Two boxes for a ticket triage agent: today it reads tickets, and maybe later it closes tickets. A dashed box asks for the scope design.
Typed schematic, not captured output.
26. You design scopes for a GitHub MCP server with levels for read repo, write PR, approve PR and merge PR. Which policy fits?Design
Four boxes for GitHub MCP server levels: read repo, write PR, approve PR and merge PR, each with a dashed box asking for its scope.
Typed schematic, not captured output.
27. You run a remote MCP server that many different clients must connect to, with no manual registration or per-client setup. Which combination fits?Design
A remote MCP server box connected to four client boxes, with text saying no manual registration and no per-client setup, and a dashed box asking for the combination.
Typed schematic, not captured output.

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.

Check: Use It0/2
1. The demo asks for `list`, then `create`, then `delete`. How many step-ups occur, and what does each one run?Apply
Three demo steps from main.py: the user asks to list, create and delete, needing notes:read, notes:write and notes:delete. A dashed box asks how many step-ups occur and what each runs.
Scope per tool from main.py. Typed schematic, not captured output.
2. The demo's last step sends the notes token to a different server. What happens?Apply
A client holding the notes token calls list on another server at github.example.com, with a dashed box asking how that server answers.
Host from main.py's demo. Typed schematic, not captured output.

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.

Author's Note

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

  1. Run code/main.py. Trace the two-scope step-up flow. Note which hops repeat on step-up.

  2. 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.

  3. Implement the protected-resource metadata endpoint as a real HTTP response using stdlib http.server. Mirror the /mcp endpoint from Lesson 09.

  4. Design a scope hierarchy for a GitHub MCP server: read repo, write PR, approve PR, merge PR, admin. Use step-up between each level.

  5. 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

TermWhat people sayWhat 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

Code

Ships With