
This lesson is part of Learn MCP, our free course on the Model Context Protocol, within The MCP Guide.
In this lesson you will protect the remote bookshop server with OAuth. You will make it reject requests without a valid token, publish the discovery document clients need, read who is calling inside a tool, and check scopes. You will do it in Python and see the TypeScript equivalent.
Here is the most useful idea in this lesson: your MCP server does not sign anyone in. In OAuth terms it is a resource server. The Python SDK's authorization guide says it "never signs anyone in and it never issues a token." It checks the token on each request. A separate authorization server, usually your identity provider, does the signing in.
The Python code adapts that guide and ran locally on 28 October 2026 with mcp 2.3.0. The TypeScript code is adapted from the TypeScript authorization guide and was not run.
Key Takeaways
- The authorization spec makes auth optional, but a remote server with private data needs it, and when used it is based on OAuth 2.1.
- Your server is the resource server: it verifies tokens and never issues them.
- Servers must publish OAuth Protected Resource Metadata (RFC 9728) so clients can find the authorization server.
- Servers must accept only tokens issued for them (audience check) and must not pass client tokens through to other APIs.
- Auth applies only over HTTP; a stdio server's security boundary is the process that launched it.
Step 1: Know the three parties
| Party | Who it is | What it does |
|---|---|---|
| Authorization server | Your identity provider (the Python guide names Auth0, Keycloak and Entra as examples) | Signs the user in and issues access tokens |
| Resource server | Your MCP server | Verifies the token on every request |
| Client | The MCP host, such as Claude or an IDE | Discovers the authorization server, gets a token, sends it as Authorization: Bearer <token> |
The client finds your authorization server by itself. When it calls your server without a token, it gets a 401 that points to a metadata document. That document names the authorization server. Our MCP authentication and OAuth guide walks through the whole flow in plain English.
Step 2: Add a token verifier in Python
from pydantic import AnyHttpUrl
from mcp.server import MCPServer
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.server.auth.settings import AuthSettings
from mcp.server.mcpserver.exceptions import ToolError
RESOURCE = "http://127.0.0.1:8000/mcp"
KNOWN_TOKENS = {
"alice-token": AccessToken(
token="alice-token", client_id="alice", scopes=["orders:read"], resource=RESOURCE
),
}
class StaticTokenVerifier(TokenVerifier):
"""Demo only. A real verifier checks a JWT or calls token introspection."""
async def verify_token(self, token: str) -> AccessToken | None:
return KNOWN_TOKENS.get(token)
mcp = MCPServer(
"Bookshop",
token_verifier=StaticTokenVerifier(),
auth=AuthSettings(
issuer_url=AnyHttpUrl("https://auth.example.com"),
resource_server_url=AnyHttpUrl(RESOURCE),
required_scopes=["orders:read"],
validate_token_resource=True,
),
)
@mcp.tool()
def my_orders() -> str:
"""List the signed-in customer's orders."""
token = get_access_token()
if token is None:
raise ToolError("Not signed in.")
return f"Orders for {token.client_id}: none yet."
if __name__ == "__main__":
mcp.run(transport="streamable-http")
What each piece does, per the authorization guide:
TokenVerifierhas one async method. It gets the raw token and returns anAccessTokenif valid, orNoneif not.issuer_urlnames the authorization server that issues your tokens.resource_server_urlis the public URL of this MCP endpoint. It identifies which resource a token is for.required_scopesmust all be present on every token.validate_token_resource=Truerefuses tokens issued for a different resource. The guide says leaving it unset triggers a deprecation warning and behaves asFalse, and that version 3.0 will makeTruethe default.get_access_token()returns the verified token inside any tool. Over stdio or in tests it returnsNone.
Step 3: Watch discovery and rejection work
Start the server and probe it:
uv run python auth_server.py
curl -s http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp
The SDK served this metadata document, which you never wrote:
{"resource":"http://127.0.0.1:8000/mcp","authorization_servers":["https://auth.example.com/"],"scopes_supported":["orders:read"],"bearer_methods_supported":["header"]}
A request with no token got:
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"
That resource_metadata pointer is what makes discovery automatic. The client follows it, finds authorization_servers, gets a token, and retries.
With the token, the Inspector call succeeded:
npx @modelcontextprotocol/inspector --cli --transport http \
--server-url http://127.0.0.1:8000/mcp \
--header "Authorization: Bearer alice-token" \
--protocol-era modern --method tools/call --tool-name my_orders --format json
It returned "Orders for alice: none yet." Without the header and with --stored-auth-only, the Inspector exited with code 3, auth_required.
Step 4: See the TypeScript version
The TypeScript SDK splits the same job across requireBearerAuth (the gate), a verifier function you write, and a metadata router. On a web-standard host such as Cloudflare Workers, the guide's pattern is:
const gate = requireBearerAuth({ verifier, requiredScopes: ['mcp'], expectedResource: mcpServerUrl });
const handler = createMcpHandler(buildServer);
export default {
async fetch(request: Request): Promise<Response> {
const auth = await gate(request);
if (auth instanceof Response) return auth;
return handler.fetch(request, { authInfo: auth });
}
};
Details from the guide that are easy to miss:
- A missing or expired token gets
401 invalid_token; a valid token missing a required scope gets403 insufficient_scope. expectedResourceturns on the audience check. Your verifier must report the token's audience inAuthInfo.resource.- Always set
expiresAtfrom the JWTexpclaim. The guide warns thatrequireBearerAuthrejects a token whoseexpiresAtis unset. @modelcontextprotocol/express2.0.1 did not passexpectedResourceon, so upgrade it together with the server package.- Tools read the caller as
ctx.http?.authInfo.
Step 5: Replace the demo verifier with a real one
The static table is for learning only. A production verifier does one of two things:
- Verifies a JWT locally: checks the signature against the authorization server's published keys, then the issuer, audience and expiry.
- Calls token introspection (RFC 7662): asks the authorization server whether the token is active. The Python guide points to an
IntrospectionTokenVerifierin the SDK'sexamples/servers/simple-auth/folder.
Whichever you choose, the authorization spec sets hard rules for your server:
- It must validate that access tokens were issued specifically for it as the intended audience.
- Invalid or expired tokens must receive an HTTP 401.
- It must not accept or transit any other tokens.
The last rule rules out "token passthrough," where a server forwards the client's token to an upstream API. The security best practices page calls it an anti-pattern. If your tools call another API, your server gets its own credentials for it. Our page on token passthrough explains the risk.
Step 6: Enforce scopes per tool
required_scopes gates the whole server. Individual tools often need more. In Python, read the token and refuse:
@mcp.tool()
def cancel_order(order_id: str) -> str:
"""Cancel one of the signed-in customer's orders."""
token = get_access_token()
if token is None or "orders:write" not in token.scopes:
raise ToolError("This action needs the orders:write permission.")
return f"Cancelled {order_id}."
In TypeScript, the guide adds scopeChallenge: requireScopes('orders:write') to the tool's registration. That sends a 403 insufficient_scope before the tool runs, which lets the client ask the user for more access.
Also bind data to the user. If a tool takes an order ID, check that the order belongs to the token's user. The security best practices page says servers must not treat possession of a state handle as authentication.
Step 7: Pick an authorization server
You need one. Options described in docs opened for this lesson:
- An existing identity provider you already run, which the Python and TypeScript SDK docs recommend over building your own.
- Cloudflare's Workers OAuth Provider, which its remote MCP guide uses to build an authorization server Worker in front of an MCP server Worker.
- Vercel's
withMcpAuthfrommcp-handler, which verifies tokens and publishes metadata. Vercel's docs say the package "does not issue tokens or run an authorization server."
On the client side, the spec says authorization servers and clients should support Client ID Metadata Documents, and that Dynamic Client Registration is deprecated. Check that your provider supports what your target hosts use.
Try it
- Add a second token,
bob-token, with scopesorders:readandorders:write. - Add the
cancel_ordertool from Step 6. - Call it as Alice and as Bob through the Inspector with
--header. Alice should get a readable tool error; Bob should succeed. - Change Bob's token
resourceto a different URL and confirm the server now rejects it.
Recap
- Your MCP server verifies tokens; an authorization server issues them.
- Publish protected resource metadata and answer 401 with a
resource_metadatapointer. Both SDKs do this for you. - Check audience, expiry and scopes; never pass client tokens through.
- Use a real identity provider and a real verifier before going live.
Next step
Lesson 23 turns from security to usability: designing tools a model uses well. For the spec details, read MCP authorization and protected resource metadata. If you need OAuth set up against your company's identity provider, see eSEOspace MCP development.
Course navigation
Previous lesson: Lesson 21: Deploy a Remote Server.
Next lesson: Lesson 23: Design Tools an AI Uses Well.
Back to the Learn MCP course outline.
Frequently asked questions
Does a local stdio server need OAuth?
No. The authorization spec says implementations using stdio should not follow it and should retrieve credentials from the environment. The Python guide adds that a stdio server's security boundary is the process that launched it.
Can I embed an authorization server inside my MCP server?
The Python SDK still has an option for it, but its guide says new servers should not reach for it. The TypeScript SDK froze its old authorization-server helpers in a legacy package. Both point you to a dedicated identity provider.
Can I just use an API key instead?
For private, internal servers some teams do, by checking a header. But MCP hosts expect the OAuth discovery flow, so many will not know how to send your key. OAuth is the interoperable choice for anything users connect to themselves.
What scopes should I define?
Start small: one read scope and one write scope per area of data. The security best practices page recommends requesting minimal scopes and asking for more only when needed.
How do I test auth in CI without a browser?
Use the Inspector CLI with --header for a known test token, or --stored-auth-only with a pre-seeded token store. Also assert that a run without a token exits with code 3, so you notice if the server ever becomes public by mistake.











