cd /news/agent-protocols/mcp-oauth-2-1-in-practice-authorizat… · home › topics › agent-protocols › article
[ARTICLE · art-139708] src=dev.to ↗ pub= topic=agent-protocols verified=true sentiment=· neutral

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

A developer detailed a production-hardening approach to MCP OAuth 2.1, covering authorization server discovery via the /.well-known/oauth-protected-resource document, PKCE code challenge generation, RFC 8707 resource indicators, and four-part token validation. The writeup argues that most MCP implementations skip audience binding and scope checks, leaving servers vulnerable to cross-server token replay and over-privileged tool calls. It also recommends keeping token lifecycle separate from MCP session state so token refreshes don't reset in-progress agent workflows.

read4 min views2 publishedSep 25, 2026

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.

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

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.

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"
}

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.

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
)

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.

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)

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.

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:

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

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.

/.well-known/oauth-protected-resource with a correct resource field?aud, not just signature and expiry? 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.

── more in #agent-protocols 4 stories · sorted by recency
── more on @mcp 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/mcp-oauth-2-1-in-pra…] indexed:0 read:4min 2026-09-25 · —