Guide
How to add OAuth to an MCP server
Last verified: 10 August 2026 · spec 2026-07-28 · ruleset 1.4.2
401 challenge that points somewhere, RFC 9728 Protected Resource Metadata, audience validation on every request, and correct 403 handling for scope failures.Get the roles right first
The single most expensive wrong turn here is building an authorization server. MCP does not ask you to. Three parties, three jobs:
| Party | Does | You build it? |
|---|---|---|
| MCP client | Runs the flow, validates iss, sends the token | No |
| Authorization server | Authenticates users, issues tokens, publishes metadata | No — use an existing one |
| Your MCP server | Challenges, publishes PRM, validates tokens | Yes — this page |
1. Return a 401 that tells the client where to go
MCP-AUT-001An unauthenticated request must be refused in a way the client can act on. A bare 401 is a dead end; the header is the whole point.
“HTTP/1.1 401 Unauthorized. WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource".”
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"const PRM_URL = "https://mcp.example.com/.well-known/oauth-protected-resource"
function challenge(res, extra = "") {
res.setHeader(
"WWW-Authenticate",
`Bearer resource_metadata="${PRM_URL}"${extra}`
)
return res.status(401).json({
jsonrpc: "2.0", id: null,
error: { code: -32001, message: "Unauthorized" }
})
}
app.post("/mcp", (req, res, next) => {
const auth = req.headers.authorization
if (!auth?.startsWith("Bearer ")) return challenge(res)
next()
})“MCP servers SHOULD include a scope parameter in the WWW-Authenticate header as defined in RFC 6750 Section 3.”
Adding scope to the challenge is SHOULD-level and cheap: it tells the client which scopes to request rather than making it guess and retry.
“MCP servers MUST implement OAuth 2.0 Protected Resource Metadata (RFC9728).”
{
"resource": "https://mcp.example.com",
"authorization_servers": [
"https://auth.example.com"
],
"scopes_supported": ["mcp:read", "mcp:write"],
"bearer_methods_supported": ["header"]
}“The resource parameter MUST identify the MCP server that the client intends to use the token with, using the canonical URI as defined in RFC 8707 Section 2.”
resource must be the canonical URI of the server the token is for — not a display name, not a path variant. A client uses this value as the resource parameter when requesting a token, and your audience check in step 3 compares against the same value. If those two disagree, every token you issue is rejected by your own validation.
“MCP Servers (Protected Resources) SHOULD NOT include offline_access in WWW-Authenticate scope or Protected Resource Metadata scopes_supported.”
SHOULD NOT for MCP resource servers, in both scopes_supported and the WWW-Authenticate challenge. Refresh-token lifetime is the authorization server's concern; advertising it from a resource server invites clients to request long-lived access you have no way to revoke.3. Validate the token — audience first
MCP-AUT-008This is the check that most matters and is most often skipped, because a server that merely verifies a signature appears to work.
“MCP servers MUST validate that access tokens were issued specifically for them as the intended audience, according to RFC 8707 Section 2.”
import { createRemoteJWKSet, jwtVerify } from "jose"
const JWKS = createRemoteJWKSet(
new URL("https://auth.example.com/.well-known/jwks.json")
)
const CANONICAL_URI = "https://mcp.example.com" // === PRM "resource"
async function verify(token) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: "https://auth.example.com",
audience: CANONICAL_URI, // ← the RFC 8707 check
})
return payload
}const res = await fetch("https://auth.example.com/oauth/introspect", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ token }),
})
const info = await res.json()
if (!info.active) throw new Error("inactive token")
// aud may be a string or an array — handle both
const aud = Array.isArray(info.aud) ? info.aud : [info.aud]
if (!aud.includes(CANONICAL_URI)) throw new Error("wrong audience")4. Handle scope failures with 403, not another 401
MCP-AUT-007“The server SHOULD respond with HTTP 403 Forbidden and a WWW-Authenticate header with error="insufficient_scope".”
// No token, or an invalid one → authenticate
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="…"
// Valid token, insufficient scope → get different scopes
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="mcp:write"Returning 401 for a scope failure sends the client back through a full authentication it does not need, and it will usually arrive with the same insufficient scopes — a loop rather than a recovery.
Three properties of your authorization server determine whether MCP clients can work with it at all. None are yours to implement, but all are yours to check before you name it in your PRM.
“MCP authorization servers MUST provide at least one of: OAuth 2.0 Authorization Server Metadata (RFC8414) or OpenID Connect Discovery 1.0.”
“Authorization servers providing OpenID Connect Discovery 1.0 MUST include code_challenge_methods_supported in their metadata to ensure MCP compatibility.”
“Authorization servers and MCP clients SHOULD support OAuth Client ID Metadata Documents. Dynamic Client Registration is deprecated and retained for backwards compatibility.”
curl -s https://auth.example.com/.well-known/oauth-authorization-server | jq '{
pkce: .code_challenge_methods_supported,
iss: .authorization_response_iss_parameter_supported,
cimd: .client_id_metadata_document_supported
}'
# Want:
# pkce contains "S256"
# iss true ← RFC 9207
# cimd true ← DCR is deprecatedThe iss one is worth understanding rather than just checking — it closes the authorization-server mix-up attack, and it is a client obligation your server can only enable. Full explanation.
What not to build
- An authorization server. Token issuance, PKCE, key rotation, client registration — none of it is asked of an MCP server, and all of it is security-critical.
- Session-based auth. There are no sessions in 2026-07-28. Credentials arrive on every request or not at all — see statelessness.
- Dynamic Client Registration as the plan. It is deprecated and retained for backwards compatibility. Target Client ID Metadata Documents.
- Bearer tokens over plain HTTP. OAuth URLs must use HTTPS, loopback redirects excepted. We check this as
MCP-SEC-002.
The 11 authorization rules we check
| Rule | What we check | Level | If it fails |
|---|---|---|---|
| MCP-AUT-001 | Unauthenticated request returns 401 with WWW-Authenticate resource_metadata | MUST | fail |
| MCP-AUT-002 | RFC 9728 Protected Resource Metadata is served and valid | MUST | fail |
| MCP-AUT-003 | PRM resource equals the canonical server URI | MUST | fail |
| MCP-AUT-004 | Each listed authorization server exposes RFC 8414 or OIDC discovery metadata | MUST | fail |
| MCP-AUT-008 | Tokens with a foreign audience are rejected | MUST | fail |
| MCP-AUT-010 | AS advertises S256 in code_challenge_methods_supported | MUST | fail |
| MCP-AUT-005 | AS advertises authorization_response_iss_parameter_supported: true | SHOULD | warn |
| MCP-AUT-006 | WWW-Authenticate includes a scope parameter | SHOULD | warn |
| MCP-AUT-007 | Insufficient scope returns 403 with error="insufficient_scope" | SHOULD | warn |
| MCP-AUT-011 | AS supports Client ID Metadata Documents, not DCR alone | SHOULD | warn |
| MCP-AUT-009 | offline_access absent from scopes_supported / challenge scope | SHOULD_NOT | info |
All eleven carry verbatim spec sentences. Security-relevant failures cap a report grade at D.
Check the whole chain from outside
The validator walks the same path a client does — 401 challenge, Protected Resource Metadata, each authorization server's metadata — and reports all eleven rules, which is the fastest way to find a canonical-URI mismatch.
Validate a server →Frequently asked
How do I add OAuth to an MCP server?
Treat your server as an OAuth 2.1 resource server, not an authorization server. Return HTTP 401 with a WWW-Authenticate header pointing at your Protected Resource Metadata; serve that metadata at /.well-known/oauth-protected-resource per RFC 9728, naming the authorization servers you trust; then validate on every request that the access token was issued for your server as its audience.
Do I need to build my own OAuth authorization server?
Almost certainly not. An MCP server is a resource server: it validates tokens, it does not issue them. Use an existing authorization server — your identity provider, or a hosted one — and name it in your Protected Resource Metadata. Building an authorization server means owning token issuance, PKCE, key rotation and client registration, none of which MCP asks of you.
What is RFC 9728 Protected Resource Metadata?
A JSON document at /.well-known/oauth-protected-resource that tells a client which authorization servers can issue tokens for your server, and what the canonical identifier for your server is. MCP servers MUST implement it — it is how a client discovers where to authenticate after receiving your 401.
Why must I validate the token audience?
Because without it, a token issued for a different service by the same authorization server would be accepted by yours. The spec requires servers to validate that access tokens were issued specifically for them as the intended audience, per RFC 8707. Skipping this turns any other client of your identity provider into a client of your MCP server.
What status code should an MCP server return for insufficient scope?
403 Forbidden with a WWW-Authenticate header carrying error="insufficient_scope", rather than another 401. The distinction matters to clients: 401 means authenticate, 403 with insufficient_scope means you are authenticated but need different scopes, which is a different recovery path.
Is Dynamic Client Registration still recommended?
No. The 2026-07-28 spec says authorization servers and MCP clients SHOULD support OAuth Client ID Metadata Documents, and that Dynamic Client Registration is deprecated and retained only for backwards compatibility. New work should target Client ID Metadata Documents.