DEV Community

QuietDesk Studio
QuietDesk Studio

Posted on

MCP OAuth 2.1 in Practice: Authorization Server Discovery, PKCE, and Token Validation That Actually Works

Most MCP OAuth guides stop at "point your client at an authorization server and get a token." That's the easy 20%. The other 80% — the part that decides whether a compromised client can impersonate every user on your server — is discovery, audience binding, and token validation. This post walks through that part with actual code.

Why MCP OAuth isn't "just OAuth"

The MCP authorization spec is built on OAuth 2.1, but it adds constraints that a lot of implementations skip:

  • The MCP server acts as an OAuth resource server, not the authorization server itself in most production setups. It validates tokens; it doesn't issue them.
  • Clients must discover the authorization server via protected resource metadata (RFC 9728) rather than hardcoding endpoints.
  • Tokens must be audience-restricted to the specific MCP server — a token minted for Server A must not work against Server B, even if both trust the same identity provider.
  • PKCE is mandatory, not optional, because MCP clients are often public clients (CLI tools, desktop apps) that can't hold a client secret safely.

Skip any of these and you get a server that "works" in a demo with one client and one user, and quietly becomes a multi-tenant data leak in production.

Step 1: Publish protected resource metadata

Your MCP server needs to tell clients which authorization server to use. This is a static JSON document at a well-known path:

// GET /.well-known/oauth-protected-resource
{
  "resource": "https://mcp.yourcompany.com",
  "authorization_servers": ["https://auth.yourcompany.com"],
  "bearer_methods_supported": ["header"],
  "resource_documentation": "https://mcp.yourcompany.com/docs"
}
Enter fullscreen mode Exit fullscreen mode

The resource field matters more than it looks — it's what you'll check against the token's aud claim later. If you skip this document, every client has to be manually configured with your auth server's URL, which is exactly the kind of hardcoded assumption that breaks the first time you rotate identity providers.

Step 2: PKCE, end to end

Your client generates a code verifier and challenge before redirecting to the authorization server:

import secrets, hashlib, base64

code_verifier = secrets.token_urlsafe(64)
code_challenge = base64.urlsafe_b64encode(
    hashlib.sha256(code_verifier.encode()).digest()
).decode().rstrip("=")

auth_url = (
    f"{auth_server}/authorize"
    f"?response_type=code&client_id={client_id}"
    f"&redirect_uri={redirect_uri}"
    f"&code_challenge={code_challenge}"
    f"&code_challenge_method=S256"
    f"&resource={mcp_server_url}"  # RFC 8707 resource indicator
)
Enter fullscreen mode Exit fullscreen mode

That resource parameter is the piece most tutorials leave out. It's what tells the authorization server which audience to bake into the token, so the token it issues can't be replayed against a different MCP server. Without it, a user who authorizes your MCP server today could have that same token work against a malicious server tomorrow if the auth server doesn't scope tokens per-resource.

Step 3: Validate the token like you mean it

This is where most "production" MCP servers fall short. Validating a token means checking four things, not one:

Check What it catches What happens if you skip it
Signature / introspection Forged tokens Anyone can mint a fake token
aud claim matches your resource URL Tokens issued for a different service Cross-server token replay
exp / nbf Expired or not-yet-valid tokens Sessions that never die
Scope sufficiency for the requested tool Over-privileged calls A read-only token deleting records
def validate_token(token: str, expected_audience: str) -> TokenClaims:
    claims = jwt.decode(token, key, algorithms=["RS256"], audience=expected_audience)
    if claims["exp"] < time.time():
        raise TokenExpired()
    if expected_audience not in claims.get("aud", []):
        raise AudienceMismatch()
    return TokenClaims(**claims)
Enter fullscreen mode Exit fullscreen mode

Teams almost always get the signature check right — libraries do that for you. They almost as often skip the audience check, because it requires knowing what "your own identity" is as a resource server, which means you actually have to do the metadata step from Step 1.

Step 4: Sessions are not the same thing as tokens

A valid OAuth token proves who the caller is. It doesn't tell you what state their MCP session is in. Conflating the two is how you get bugs where a token refresh silently resets an agent's in-progress multi-step workflow.

Keep them separate:

  • Token: short-lived (15–60 min), carries identity and scopes, validated on every request.
  • Session: server-side state keyed by a session ID the client sends alongside the token, holding conversation/tool-call context, with its own TTL and idle timeout independent of the token's lifetime.

When a token expires mid-session, refresh the token without invalidating the session. When a session expires, require a fresh authorization handshake even if the token is technically still valid — an idle session is a bigger risk surface than a rotated token.

The failure mode that actually happens in the wild

The most common real-world break isn't a missing signature check — it's audience confusion in multi-tenant setups where one authorization server issues tokens for several MCP servers under the same organization. A team validates the signature, checks expiry, and calls it done. Six months later, a token minted for the internal analytics MCP server also works against the customer-facing one, because nobody checked aud. That's not a hypothetical; it's the exact gap the resource indicator in Step 2 and the audience check in Step 3 exist to close.

Where this fits with what we've already covered

If you haven't seen the walkthrough on wiring up auth, sessions, and error recovery in a minimal server, that post covers the end-to-end request lifecycle this one zooms into. And if you're deciding whether you need any of this OAuth machinery at all, the piece on MCP vs. simple scripts has the decision framework for when a stateless script beats a full MCP server with session management.

If you'd rather start from code that already implements this correctly than assemble it from blog posts, the AgentKitLab MCP Production Checklist pack includes a minimal working server with the discovery document, PKCE flow, and audience-validated token handling shown above already wired up, plus a checklist to audit an existing server against and agent-eval test templates to catch regressions before they ship. It's $9–$29 depending on the tier.

Quick reference: the four checks before you ship

  1. Do you publish /.well-known/oauth-protected-resource with a correct resource field?
  2. Does your client send a resource parameter (RFC 8707) during authorization?
  3. Does your validation logic check aud, not just signature and expiry?
  4. Are sessions tracked independently of token lifetime, with their own idle timeout?

If you can't answer yes to all four, that's your next PR — not a nice-to-have, but the difference between an OAuth flow that looks done and one that's actually safe to point real users at.

Written with AI assistance and reviewed for accuracy.


Written with AI assistance and reviewed for accuracy.

Top comments (0)