Menu

AI

Lesson 22: Add OAuth Sign-In

Protect a remote MCP server with OAuth 2.1 bearer tokens in Python or TypeScript, publish protected resource metadata, check audience and scopes, and test it.

By: Irina ShvayaOctober 28, 2026

Lesson 22: Add OAuth Sign-In

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

PartyWho it isWhat it does
Authorization serverYour identity provider (the Python guide names Auth0, Keycloak and Entra as examples)Signs the user in and issues access tokens
Resource serverYour MCP serverVerifies the token on every request
ClientThe MCP host, such as Claude or an IDEDiscovers 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:

  • TokenVerifier has one async method. It gets the raw token and returns an AccessToken if valid, or None if not.
  • issuer_url names the authorization server that issues your tokens.
  • resource_server_url is the public URL of this MCP endpoint. It identifies which resource a token is for.
  • required_scopes must all be present on every token.
  • validate_token_resource=True refuses tokens issued for a different resource. The guide says leaving it unset triggers a deprecation warning and behaves as False, and that version 3.0 will make True the default.
  • get_access_token() returns the verified token inside any tool. Over stdio or in tests it returns None.

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 gets 403 insufficient_scope.
  • expectedResource turns on the audience check. Your verifier must report the token's audience in AuthInfo.resource.
  • Always set expiresAt from the JWT exp claim. The guide warns that requireBearerAuth rejects a token whose expiresAt is unset.
  • @modelcontextprotocol/express 2.0.1 did not pass expectedResource on, 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:

  1. Verifies a JWT locally: checks the signature against the authorization server's published keys, then the issuer, audience and expiry.
  2. Calls token introspection (RFC 7662): asks the authorization server whether the token is active. The Python guide points to an IntrospectionTokenVerifier in the SDK's examples/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 withMcpAuth from mcp-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

  1. Add a second token, bob-token, with scopes orders:read and orders:write.
  2. Add the cancel_order tool from Step 6.
  3. Call it as Alice and as Bob through the Inspector with --header. Alice should get a readable tool error; Bob should succeed.
  4. Change Bob's token resource to 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_metadata pointer. 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.

You Might Also like to Read

Get a FREE Website Audit

Enter your website and get a 20–30 page SEO, GEO, AEO & CRO audit within 24 hours. Completely free.

Don't have a site yet? Get in touch →

Ready to speak with a marketing expert?
Give us a call! (916) 866-7893

Websites Launched
1407
Websites Launched
Marketing Experts
29
Marketing Experts
Hours of Dedicated Work
204533
Hours of Dedicated Work
Irina S.Benjamin G.Jeanette M.David G.