Securing an MCP Server: Audience Validation, PRM, and the Confused Deputy
If you're building an MCP server, you've probably read how MCP authorization works and concluded — correctly — that you don't have to invent anything. Your MCP server is an OAuth 2.1 resource server: it validates tokens, it never issues them. An authorization server does the issuing.
But "just validate the token" hides the part that actually keeps you safe. A bearer token is valid in the cryptographic sense the moment its signature checks out — and that is not enough. The one property that stops a token stolen from your calendar server from also opening your email server is audience binding, and verifying it is a step most homegrown servers skip. Below is the hardening checklist for an MCP server: publish protected-resource metadata, validate the audience, refuse to pass tokens through, and close the confused-deputy door — each with the RFC that mandates it.
Your job as a resource server is narrow — do it completely
A resource server has exactly two responsibilities, and the failure mode is doing the first without the second:
- Tell clients where to get a token — via Protected Resource Metadata (RFC 9728) and a
WWW-Authenticatechallenge. - Validate every token before trusting it — signature, expiry, and audience.
That's the whole job. You do not run a login screen, store passwords, or issue tokens. If you find yourself writing token-issuing code in your MCP server, stop — that's the authorization server's job, and conflating the two is the original mistake the MCP spec moved away from.
Step 1 — Publish Protected Resource Metadata (RFC 9728)
When an agent hits your server with no token, you can't just return a bare 401. The agent needs to discover which authorization server to talk to. RFC 9728 defines how: a metadata document at a well-known path, advertised in your WWW-Authenticate header.
Return this on any unauthenticated request:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.yourapp.in/.well-known/oauth-protected-resource"And serve the document it points at:
{
"resource": "https://mcp.yourapp.in",
"authorization_servers": ["https://api.namoid.in"],
"bearer_methods_supported": ["header"]
}The resource value is your server's canonical URI — remember it, because every token you accept must be bound to exactly this value. The agent reads authorization_servers, fetches that server's metadata (RFC 8414), and runs the OAuth flow against it. You never see a password; you just point the way.
Step 2 — Validate the audience, not just the signature
Here's the catch most teams miss. An access token carries an aud (audience) claim — the resource it was minted for (RFC 9068, the JWT access-token profile). The authorization server sets it from the resource parameter the client sent (RFC 8707 Resource Indicators). Your server must reject any token whose aud is not your canonical URI — even if the signature is perfectly valid.
Skip this check and you've built a confused deputy: a token issued for someone else's server sails straight into yours because the signature is fine and you never looked at who it was for.
import jwt # PyJWT
from jwt import PyJWKClient
CANONICAL_URI = "https://mcp.yourapp.in"
JWKS = PyJWKClient("https://api.namoid.in/.well-known/jwks.json")
def validate(token: str) -> dict:
signing_key = JWKS.get_signing_key_from_jwt(token).key
return jwt.decode(
token,
signing_key,
algorithms=["RS256"], # pin the alg — never trust the header
audience=CANONICAL_URI, # the check that closes the deputy door
options={"require": ["exp", "aud"]},
)Three things that are easy to get wrong, all shown above:
- Pin the algorithm. Pass
algorithms=["RS256"]explicitly. Accepting whatever the token's header claims is how alg-confusion attacks work (alg: none, or RS256→HS256 using your public key as an HMAC secret). - Require
aud. A token with no audience claim should fail closed, not pass. - Fetch keys from JWKS, by
kid. Don't hardcode a public key; let the client library pick the right one from the published key set so signing-key rotation doesn't break you.
If the audience doesn't match, return the same 401 + WWW-Authenticate from Step 1 (or a 403 if you prefer to signal "valid token, wrong door"). Either way: do not serve the request.
Step 3 — Never pass the token through
Your MCP server often needs to call other APIs to do its work. The tempting shortcut — forward the token the agent gave you to those downstream APIs — is explicitly forbidden by the MCP authorization spec, and for good reason: the token's aud is your server, not the downstream one. Passing it through either fails (if the downstream validates audience, as it should) or, worse, succeeds against a downstream service that doesn't check — turning your server into a token-laundering hop.
The rule: a token minted for resource A is used only at resource A. When your MCP server needs to call a downstream service, it gets its own credential for that service — its own client-credentials token, or an exchanged token scoped to the downstream audience. The agent's token stops at your door.
Step 4 — The confused deputy, closed for good
Steps 2 and 3 together close the confused-deputy class entirely:
- Audience validation (Step 2) means a token for another resource is rejected at your server.
- No passthrough (Step 3) means your server never lends its trust to a downstream call it didn't authorize itself.
Add one more layer for multi-tool agents: per-resource consent. When the user authorizes the agent, the consent should name the specific resource(s) the agent may act on — so a token for "read my calendar" was never something the user agreed to use against "send my email" in the first place. A good authorization server records that consent per connection; your server just has to honor the audience that results.
Onboarding agents: prefer CIMD over Dynamic Client Registration
One practical question: how does an agent you've never seen become a registered OAuth client? The old answer was Dynamic Client Registration (RFC 7591) — a /register endpoint. The 2025-11-25 MCP revision moved to Client ID Metadata Documents (CIMD) instead: the client's client_id is itself an HTTPS URL pointing at a metadata document, which the authorization server fetches and validates on first use. No registration endpoint, no shared secret, no pre-provisioning — the agent shows up with a URL and the authorization server resolves it.
This is an authorization-server concern, not yours — but it's worth knowing which onboarding model your AS supports, because it determines whether arbitrary agents can connect at all. (NamoID resolves CIMD client_id URLs and registers them as public, PKCE-authenticated clients automatically; nothing to wire on the MCP-server side.)
The hardening checklist
☐ 401 responses carry WWW-Authenticate with resource_metadata=
☐ /.well-known/oauth-protected-resource served, with canonical `resource` + authorization_servers
☐ Token validated: signature (JWKS by kid), exp, AND aud == your canonical URI
☐ Algorithm pinned (RS256), `aud` required — fail closed on either missing
☐ No token passthrough — downstream calls use the server's own credential
☐ Per-resource consent honored (audience reflects what the user actually approved)
Run that list and the three failure modes every agent security review asks about — token theft, the confused deputy, and passthrough — are closed by construction, not by hope.
The reason this is mostly a checklist and not a project is that the hard part lives in the authorization server: minting audience-bound tokens (RFC 8707), publishing discovery metadata (RFC 8414), and onboarding agents via CIMD. NamoID does all three, so your MCP server's job stays narrow: publish PRM, check the audience, and never pass the token through. If you're standing one up, the MCP server guide walks the resource-server side end to end.