Authorization
Knowing who is asking
Transport hardening decides which pages may reach your server. Authorization decides which people may, and what each of them may do.
MCP servers use OAuth 2.1, and the important thing to be clear about is your role. You are the resource server — you receive access tokens and decide what they permit. You are not, usually, the authorization server: that is your identity provider, and it issues the tokens.
Getting that distinction right is what keeps this tractable. You are not implementing OAuth. You are validating a token and reading a scope.
Someone else's boarding pass is still a boarding pass
At the gate, checking that the thing in someone's hand is a genuine boarding pass is not the check. It is genuine. It was issued by a real airline, it has a real barcode, and it is for a different flight.
The check is the flight number.
That is audience validation, and the analogy carries the whole rule. A beginner takes away that "is this a valid token?" is the wrong question. An engineer takes away the precise failure: a signature-valid token from a trusted issuer, intended for a different service, accepted because nobody looked at the aud claim.
The rule
An MCP server must not accept a token that was not issued for it.
That is the specification's language, and it is the strongest requirement in this area. Checking the signature is not enough. Checking the issuer is not enough. You must check that the token's audience is you:
import jwt
from mcp import MCPError
AUDIENCE = "https://atrium.example/mcp"
def principal_from(ctx: Context) -> dict:
"""Validate the bearer token and return the caller, or refuse."""
header = (ctx.headers or {}).get("authorization", "")
if not header.startswith("Bearer "):
raise MCPError(-32001, "Authorization required")
try:
return jwt.decode(
header.removeprefix("Bearer "),
key=PUBLIC_KEY,
algorithms=["RS256"],
audience=AUDIENCE,
issuer=ISSUER,
)
except jwt.InvalidTokenError as exc:
raise MCPError(-32001, f"Invalid token: {exc}") from excaudience=AUDIENCE is the line that matters. Without it, a token minted for any other service in your organisation opens your server.
Token passthrough, and why it is banned
The related anti-pattern: your server receives a token from a client and forwards it, unchanged, to a downstream API.
It is tempting. The client authenticated, you have a working credential, and passing it along saves a token exchange. The specification forbids it, for reasons worth understanding rather than memorising.
- It destroys your audit trail. The downstream API logs requests as coming from the user, through an unidentified intermediary. When something goes wrong, nobody can tell which server did it.
- It bypasses controls. Rate limits, quotas and validation that key on the calling service stop working when every call looks like it came from somewhere else.
- It makes you a proxy for stolen tokens. Someone with a leaked token gets your server's network position for free.
What to do instead: exchange the incoming token for one issued to you for the downstream service, or hold your own service credential. Both mean the downstream API knows it is talking to Atrium's MCP server on behalf of a user, which is the truth.
Scopes, and asking for less
Once you have a validated principal, its scopes decide what it may do:
@mcp.tool(annotations=ToolAnnotations(destructive_hint=True, idempotent_hint=True))
def cancel_booking(booking_id: str, ctx: Context[AtriumContext] = None) -> str:
"""Cancel a booking and free the slot."""
caller = principal_from(ctx)
scopes = set(caller.get("scope", "").split())
owner = ctx.request_context.lifespan_context.bookings.owner_of(booking_id)
if "atrium:admin" not in scopes and owner != caller["sub"]:
raise MCPError(-32003, "You can only cancel your own bookings.")
...Two things worth copying. Ownership is derived from the verified token (caller["sub"]), never from an argument the caller supplied. And staff and members are different scopes, not a flag in the request.
On scope design: publish few, narrow scopes rather than one broad one. A token carrying atrium:* is a token whose theft costs you everything, and a consent screen listing everything is one users decline. atrium:read, atrium:book and atrium:admin are three scopes a person can reason about, and a compromise of the first is bounded.
What clients must do, and where SSRF enters
Clients discover a server's authorization configuration by fetching URLs the server supplies — the resource_metadata URL from a WWW-Authenticate header, then the authorization server metadata.
Those URLs come from the server, and a server may be hostile. A malicious one can point them at http://169.254.169.254/, the cloud metadata endpoint, and a client that fetches without validating becomes a credential-exfiltration tool.
If you are writing a client, this is your problem, and the next lesson draws it properly.
What to take into the next lesson
You are the resource server: validate the token's audience, not merely its signature, because a genuine token for another service is not a token for you; never forward a client's token downstream; derive identity and ownership from the verified token rather than from arguments; and keep scopes few and narrow. Next: four attacks, drawn well enough to spot in review.