Securing remote servers with OAuth
Protected resource metadata, PKCE, audiences and scope challenges.
Create a free account Try part of it here
Why it matters
A remote MCP server usually acts for a person: it reads their CRM, their calendar, their files. It must know who that person is, and what they agreed to let this app do, without the app ever seeing their password. MCP uses OAuth for this, with a few extra rules that close real attacks. When an AI app says "connect your account" and a browser window opens, this is the flow behind it.
What you'll be able to do
- Walk through MCP's authorization flow, from the first 401 to the first successful call
- Explain protected resource metadata, client registration, PKCE and the resource parameter
- Validate access tokens on the server: expiry, audience and scopes, with the right 401 and 403 answers
- Explain why token passthrough is forbidden, and what to do instead
What it covers
- Who's who
- The flow, step by step
- PKCE, concretely
- The resource parameter: tokens for one server
- Checking tokens on the server
- Four checks, one token
- No token passthrough
- No passthrough
Who's who
Think of the hotel again. The front desk checks who you are and issues a key card. The room door only checks the card. You carry the card from one to the other. OAuth has the same three roles, and MCP maps onto them:
- The MCP server is the resource server, the door: it holds the user's data and checks tokens.
- The authorization server is the front desk. It logs the user in, asks for consent and issues tokens. It's often a separate service, such as the company's identity provider.
- The MCP client (inside the host) is the OAuth client, the guest: it gets a token and sends it.
Local stdio servers don't use any of this. They get credentials from environment variables.
Show the answer
The authorization server
The authorization server is the front desk: it checks who you are and issues the token. The MCP server only checks tokens, and the MCP client only carries them.
The flow, step by step
1. The first request is refused. The client calls the server with no token. The server answers 401 with a header pointing at its protected resource metadata, a document that says how to get in:
HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer resource_metadata="https://shop.example/.well-known/oauth-protected-resource/mcp", scope="orders:read"
2. Discovery. That metadata document (defined in RFC 9728) says which authorization servers this resource trusts:
{"resource": "https://shop.example/mcp",
"authorization_servers": ["https://auth.shop.example"],
"scopes_supported": ["orders:read", "orders:write"]}
The client then fetches the authorization server's own metadata from a well-known address (/.well-known/oauth-authorization-server or /.well-known/openid-configuration). That gives the login and token endpoints and the features it supports.
3. Client registration. The authorization server needs to know which app is asking. The preferred way in the 2026-07-28 revision is a Client ID Metadata Document: the client's id is an HTTPS URL, and at that URL sits a JSON document with the app's name and allowed redirect addresses. Pre-registered ids also work. Dynamic Client Registration, which was common earlier, is now deprecated.
4. Login with PKCE. The client opens the browser at the authorization endpoint with a PKCE code challenge, the resource parameter and the scopes it needs. The user logs in and approves.
5. The code comes back. The browser is redirected to the client with a short-lived code and an iss value. The client checks that iss names the authorization server it started with (RFC 9207). That defeats attacks that mix up two authorization servers.
6. Token exchange. The client swaps the code for an access token, sending the PKCE verifier and the resource again. Then every MCP request carries Authorization: Bearer <token>, never a token in the URL.
Note the audience check: a token meant for one server must not work on another.
Show the answer
From the server's protected resource metadata, linked in the 401's WWW-Authenticate header (or at a well-known URL)
RFC 9728 metadata names the authorization servers the resource trusts. Clients must support both the header link and the well-known fallback.
PKCE, concretely
PKCE (proof key for code exchange, said "pixy") stops a stolen authorization code from being used. Picture tearing a raffle ticket in two: you hand in one half now, and only the person holding the other half can collect the prize. Before step 4, the client makes a random secret, the code verifier: 43 to 128 characters of letters, digits and -._~. It sends only its hash, the code challenge, in step 4:
import base64, hashlib, secrets def challenge(verifier): digest = hashlib.sha256(verifier.encode("ascii")).digest() return base64.urlsafe_b64encode(digest).decode().rstrip("=") # The worked example from the PKCE standard, RFC 7636: print(challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")) verifier = secrets.token_urlsafe(32) # 43 characters from the allowed set print(len(verifier), challenge(verifier))
The first line should print E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM, the published answer. In step 6 the client sends the verifier itself, and the authorization server checks that its hash matches the challenge. Someone who intercepted the code in step 5 never saw the verifier, so the code is useless to them. MCP requires the S256 method shown here.
import hashlib, base64 verifier = "abc" challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=") print(challenge == verifier)
Show the answer
False
The challenge is a long hash of the verifier, so it can't equal "abc", and == gives False. A thief who sees the challenge can't work backward to the verifier.
The resource parameter: tokens for one server
A key card coded for one hotel won't open a room in another, even if both belong to the same chain. The client must send resource=https://shop.example/mcp (the server's canonical URI) in both the authorization and token requests. The authorization server then puts that server in the token's audience (the aud claim). A token for the shop's MCP server won't work at the calendar's MCP server, even if both trust the same authorization server.
Show the answer
Exchanging it needs the PKCE code verifier, which never left the client
Only the hash went out with the login request. The token endpoint wants the original secret.
Checking tokens on the server
On every request, the MCP server validates the token, as you did with JWTs in Phase 8. A door guard asks the same four things about a pass: is it real, is it in date, is it for this building, and does it open this floor?
- It must be genuine (a valid signature, or confirmed by asking the authorization server).
- It must not be expired.
- Its audience must be this server. The spec is firm: servers must accept only tokens issued for them.
- It must carry the scopes the operation needs.
Failures have specific answers:
| problem | answer |
|---|---|
| no token, bad token, expired token, wrong audience | 401, with WWW-Authenticate: Bearer ... including resource_metadata (and error="invalid_token" for a bad one) |
| valid token without the needed scope | 403, with WWW-Authenticate: Bearer error="insufficient_scope", scope="orders:write", resource_metadata="..." |
The 403 lists the scopes needed, so the client can ask the user to approve more and retry. That's called step-up authorization.
Four checks, one token
A token can be genuine and still be wrong for this request. Work through the checks in order, and notice how each failure gets a different answer.
THIS_SERVER = "https://shop.example/mcp" def check_token(token, needed_scope, now): if not token.get("signature_ok"): return 401, "invalid token" if token["exp"] <= now: return 401, "expired" if token["aud"] != THIS_SERVER: return 401, "wrong audience" if needed_scope not in token["scope"].split(): return 403, f"needs scope {needed_scope}" return 200, "ok" good = {"signature_ok": True, "exp": 2000, "aud": THIS_SERVER, "scope": "orders:read"} print(check_token(good, "orders:read", now=1000)) print(check_token(good, "orders:write", now=1000)) print(check_token(good, "orders:read", now=3000)) print(check_token({**good, "aud": "https://other.example/mcp"}, "orders:read", now=1000))
The last token is real and unexpired, but it was issued for another server. Accepting it would let any server you've connected to replay your token against this one. A missing scope gets 403, not 401, because the caller is known but not allowed.
No token passthrough
Your MCP server may call other APIs for the user, such as the shop's order system. It's tempting to forward the token the client sent. Don't. The spec forbids it, for two reasons.
Your server would then accept tokens meant for someone else, which skips the audience check. And downstream systems would see a request that seems to come straight from the client, with your server's checks bypassed. That's a confused deputy: a trusted helper tricked into using its authority for someone else.
Your server should get its own token for the downstream API, through a separate grant, and keep each token to the audience it was issued for.
In plain words: a token opens one door. If your server needs another door, it gets its own token for it.
Show the answer
The token was issued for the MCP server; passing it on breaks audience checks and creates a confused deputy
Each service should get its own token for its own audience. That's why MCP forbids token passthrough.
No passthrough
A server must never forward the client's token to another API. Get its own token for that, so that a token only ever works where it was meant to.
Practice what you just read
- 2 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
In MCP's use of OAuth, what role does the MCP server play?