MCP Authorization: How to Protect a Remote Server with OAuth
A remote MCP server that exposes private data should require an OAuth 2.1 access token on every request. Your authorization server issues the token, and your MCP server checks its signature, issuer, audience, expiry and scopes before any tool runs. A request without a valid token gets a 401 with a WWW-Authenticate header that points to the server's Protected Resource Metadata, which is how a client finds the authorization server. Local servers that use stdio don't follow this flow; they read their credentials from the environment.
What you'll have at the end
A server on http://127.0.0.1:8790/mcp that refuses requests without a token, and runs its tools only for requests that carry a valid one. This is the result of calling the whoami tool with a valid token:
{
...
"content": [
{
"type": "text",
"text": "subject=user-123 client=test-client scopes=mcp:tools"
}
],
...
"isError": false
}
The server checks the token before it runs anything. The sequence looks like this:
Before you start
- Python: Python 3.10 or later and uv.
- TypeScript: Node.js 22.19 or later and npm. The MCP Inspector needs 22.19 or later too.
- Token helper: the step 3 helper is a Python script run with uv, so you need uv even if your server is TypeScript.
- An authorization server to issue tokens in production. You don't need one running for this guide, because the test signs its tokens with a local key pair.
- The OAuth words used below: an access token is what the client sends, a scope is a permission such as
mcp:tools, and an audience is the server a token was issued for.
1. Set up the project
- Python
- TypeScript
uv init mcp-authorization
cd mcp-authorization
uv add "mcp[cli]>=2.3.0" "pyjwt[crypto]>=2.15.1"
uv add installs the official MCP Python SDK and PyJWT, which checks the token signatures. Delete the main.py that uv init creates; the server goes in server.py.
mkdir mcp-authorization
cd mcp-authorization
npm init -y
npm install @modelcontextprotocol/server @modelcontextprotocol/express @modelcontextprotocol/node express jose zod
npm install -D @types/express @types/node typescript
mkdir src
The TypeScript SDK splits its packages. @modelcontextprotocol/server is the core, @modelcontextprotocol/express adds the bearer-token middleware and metadata routes, and @modelcontextprotocol/node connects the server to Express. jose checks the token signatures. Set package.json to use ES modules and add a build script:
{
"type": "module",
"scripts": {
"build": "tsc"
}
}
Then create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"types": ["node"],
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
2. Write the server
The server needs three things: a check on every access token, the metadata that tells a client where to sign in, and tools that can see who is calling. Both SDKs serve the metadata and the 401 challenge for you, so the code you write is the token check and the tools.
- Python
- TypeScript
import logging
import os
import sys
from pathlib import Path
from typing import Annotated
import jwt
from pydantic import AnyHttpUrl, Field
from mcp.server import MCPServer
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken
from mcp.server.auth.settings import AuthSettings
from mcp.server.mcpserver.exceptions import ToolError
# Logs go to stderr so they never mix with protocol output.
logging.basicConfig(level=logging.INFO, stream=sys.stderr)
logger = logging.getLogger("authorized-tools")
HOST = "127.0.0.1"
PORT = int(os.environ.get("MCP_PORT", "8790"))
RESOURCE_URL = os.environ.get("MCP_RESOURCE_URL", f"http://{HOST}:{PORT}/mcp")
ISSUER = os.environ.get("AUTH_ISSUER", "https://auth.example.com/")
REQUIRED_SCOPE = "mcp:tools"
class JwtTokenVerifier:
"""Accepts a bearer token only if its signature, issuer, audience, expiry and scope check out."""
def __init__(self, public_key_pem: bytes) -> None:
self._public_key = public_key_pem
async def verify_token(self, token: str) -> AccessToken | None:
try:
claims = jwt.decode(
token,
self._public_key,
algorithms=["RS256"],
audience=RESOURCE_URL,
issuer=ISSUER,
options={"require": ["exp", "iss", "aud", "sub"]},
)
except jwt.PyJWTError as error:
logger.info("Rejected access token: %s", error)
return None
return AccessToken(
token=token,
client_id=str(claims.get("azp", claims["sub"])),
scopes=str(claims.get("scope", "")).split(),
expires_at=int(claims["exp"]),
resource=RESOURCE_URL,
subject=str(claims["sub"]),
claims=claims,
)
def load_public_key() -> bytes:
key_file = os.environ.get("AUTH_PUBLIC_KEY_FILE")
if not key_file:
raise SystemExit("Set AUTH_PUBLIC_KEY_FILE to the PEM public key that signs your access tokens.")
return Path(key_file).read_bytes()
def create_server() -> MCPServer:
server = MCPServer(
"authorized-tools",
token_verifier=JwtTokenVerifier(load_public_key()),
auth=AuthSettings(
issuer_url=AnyHttpUrl(ISSUER),
resource_server_url=AnyHttpUrl(RESOURCE_URL),
required_scopes=[REQUIRED_SCOPE],
validate_token_resource=True,
),
)
@server.tool()
def add_numbers(
a: Annotated[float, Field(description="First number")],
b: Annotated[float, Field(description="Second number")],
) -> str:
"""Add two numbers together."""
return f"{a:g} + {b:g} = {a + b:g}"
@server.tool()
def whoami() -> str:
"""Report which user and client the access token belongs to."""
token = get_access_token()
if token is None:
raise ToolError("No access token was verified for this request.")
return f"subject={token.subject} client={token.client_id} scopes={' '.join(token.scopes)}"
return server
if __name__ == "__main__":
create_server().run(transport="streamable-http", host=HOST, port=PORT)
JwtTokenVerifier is the part you write. The SDK calls its verify_token method with each bearer token. jwt.decode checks the RS256 signature, that iss matches AUTH_ISSUER exactly, that aud is this server's URL, and that exp and sub are present. A token that fails any check returns None, and the SDK answers 401.
AuthSettings tells the SDK the issuer, this server's URL and the scope every token needs. With validate_token_resource=True, the SDK also refuses any token whose resource isn't this server. The SDK uses these settings to serve /.well-known/oauth-protected-resource/mcp.
get_access_token() returns the token the SDK verified for the current request, so whoami reports the caller without parsing the header itself.
import { readFileSync } from "node:fs";
import { importSPKI, jwtVerify } from "jose";
import {
createMcpExpressApp,
getOAuthProtectedResourceMetadataUrl,
mcpAuthMetadataRouter,
requireBearerAuth,
type OAuthTokenVerifier,
} from "@modelcontextprotocol/express";
import { toNodeHandler } from "@modelcontextprotocol/node";
import {
createMcpHandler,
McpServer,
OAuthError,
OAuthErrorCode,
type AuthInfo,
type OAuthMetadata,
} from "@modelcontextprotocol/server";
import { z } from "zod";
const HOST = "127.0.0.1";
const PORT = Number(process.env.MCP_PORT ?? 8790);
const RESOURCE = new URL(
process.env.MCP_RESOURCE_URL ?? `http://${HOST}:${PORT}/mcp`,
);
const ISSUER = process.env.AUTH_ISSUER ?? "https://auth.example.com/";
const REQUIRED_SCOPE = "mcp:tools";
const keyFile = process.env.AUTH_PUBLIC_KEY_FILE;
if (!keyFile) {
console.error(
"Set AUTH_PUBLIC_KEY_FILE to the PEM public key that signs your access tokens.",
);
process.exit(1);
}
const publicKey = await importSPKI(readFileSync(keyFile, "utf8"), "RS256");
// Checks the signature, issuer, audience and expiry of each access token.
const verifier: OAuthTokenVerifier = {
async verifyAccessToken(token: string): Promise<AuthInfo> {
let payload;
try {
({ payload } = await jwtVerify(token, publicKey, {
algorithms: ["RS256"],
issuer: ISSUER,
audience: RESOURCE.href,
requiredClaims: ["exp", "sub"],
}));
} catch (error) {
console.error(
"Rejected access token:",
error instanceof Error ? error.message : error,
);
throw new OAuthError(
OAuthErrorCode.InvalidToken,
"Access token is invalid",
);
}
return {
token,
clientId: String(payload.azp ?? payload.sub),
scopes: String(payload.scope ?? "").split(" ").filter(Boolean),
expiresAt: payload.exp,
resource: RESOURCE,
extra: { subject: payload.sub },
};
},
};
// Replace these with your authorization server's real endpoints.
const oauthMetadata: OAuthMetadata = {
issuer: ISSUER,
authorization_endpoint: new URL("authorize", ISSUER).href,
token_endpoint: new URL("token", ISSUER).href,
response_types_supported: ["code"],
};
function text(value: string) {
return { content: [{ type: "text" as const, text: value }] };
}
// createMcpHandler calls this for every request, so return a new McpServer each time.
function createServer() {
const server = new McpServer({ name: "authorized-tools", version: "1.0.0" });
server.registerTool(
"add_numbers",
{
description: "Add two numbers together.",
inputSchema: z.object({
a: z.number().describe("First number"),
b: z.number().describe("Second number"),
}),
},
async ({ a, b }) => text(`${a} + ${b} = ${a + b}`),
);
server.registerTool(
"whoami",
{
description:
"Report which user and client the access token belongs to.",
inputSchema: z.object({}),
},
async (_args, ctx) => {
const caller = ctx.http?.authInfo;
if (!caller) {
throw new Error("No access token was verified for this request.");
}
const subject = String(caller.extra?.subject);
return text(
`subject=${subject} client=${caller.clientId} scopes=${caller.scopes.join(" ")}`,
);
},
);
return server;
}
const app = createMcpExpressApp({ host: HOST });
app.use(
mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: RESOURCE }),
);
const auth = requireBearerAuth({
verifier,
requiredScopes: [REQUIRED_SCOPE],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(RESOURCE),
expectedResource: RESOURCE,
});
const node = toNodeHandler(createMcpHandler(createServer));
app.all("/mcp", auth, (req, res) => void node(req, res, req.body));
app.listen(PORT, HOST, () => {
console.error(`Authorized MCP server listening at ${RESOURCE.href}`);
});
verifyAccessToken makes the same checks with jose. It throws an OAuthError for a token it rejects, and requireBearerAuth turns that into the 401. requireBearerAuth also enforces the mcp:tools scope, and expectedResource accepts only tokens whose audience is this server. A token that's valid but lacks the scope gets a 403 with insufficient_scope.
mcpAuthMetadataRouter serves the Protected Resource Metadata and the authorization server metadata. The oauthMetadata object uses example.com endpoints, so replace them with your authorization server's real values before you deploy.
Whichever language you use, the public URL of the server goes in MCP_RESOURCE_URL when you deploy. The token's audience has to match it exactly, so don't leave the local 127.0.0.1 address in place.
The MCP authorization spec (2026-07-28) sets the rules this server follows:
| Rule from the spec | Where the example meets it |
|---|---|
Serve Protected Resource Metadata (RFC 9728) and point to it in the 401 WWW-Authenticate header | The /.well-known/oauth-protected-resource/mcp route and the challenge on every 401 |
| Accept only tokens issued for this server (RFC 8707 audience) | The aud check, validate_token_resource=True in Python, and expectedResource in TypeScript |
Return 401 for invalid or expired tokens and 403 for insufficient scope | Both cases are asserted in test.sh |
| Never pass a token received from a client on to an upstream API | The example calls no other API |
The Python SDK's 401 challenge has no scope parameter, and the spec recommends one. The TypeScript example's 401 includes scope="mcp:tools", so use that as the pattern if you need the scope in the challenge.
3. Start the server with a test key
Your server needs the public key that verifies tokens. This guide reads that key from a PEM file. The helper below stands in for the authorization server so the test runs offline: it creates a key pair and signs a token for this server. Copy it into your project folder. A real server gets its tokens from its authorization server, not from this script.
"""Stands in for an authorization server so the test runs offline.
Generates an RSA key pair the first time it runs in --out-dir (writing
private.pem and public.pem), then reuses that key on later runs. It prints one
signed access token to stdout. A real deployment gets tokens from its identity
provider instead; this script is only for trying the server locally.
"""
import argparse
import sys
import time
from pathlib import Path
import jwt
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
def load_or_create_key(out_dir: Path) -> rsa.RSAPrivateKey:
private_path = out_dir / "private.pem"
if private_path.exists():
loaded = serialization.load_pem_private_key(private_path.read_bytes(), password=None)
if not isinstance(loaded, rsa.RSAPrivateKey):
raise SystemExit(f"{private_path} is not an RSA key")
return loaded
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
private_path.write_bytes(
private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
)
(out_dir / "public.pem").write_bytes(
private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
)
return private_key
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out-dir", type=Path, required=True)
parser.add_argument("--issuer", required=True)
parser.add_argument("--audience", required=True)
parser.add_argument("--scope", default="mcp:tools")
parser.add_argument("--subject", default="user-123")
parser.add_argument("--expires-in", type=int, default=3600, help="Seconds; negative issues an expired token")
args = parser.parse_args()
args.out_dir.mkdir(parents=True, exist_ok=True)
private_key = load_or_create_key(args.out_dir)
now = int(time.time())
claims = {
"iss": args.issuer,
"aud": args.audience,
"sub": args.subject,
"azp": "test-client",
"scope": args.scope,
"iat": now,
"exp": now + args.expires_in,
}
print(jwt.encode(claims, private_key, algorithm="RS256"))
return 0
if __name__ == "__main__":
sys.exit(main())
Run the helper from your project folder. It writes the key pair to keys/ and stores a token in TOKEN:
export TOKEN=$(uv run --with "pyjwt[crypto]" issue_test_token.py --out-dir keys --issuer https://auth.example.com/ --audience http://127.0.0.1:8790/mcp)
The --audience value is the server's URL, so the token is valid only for that server. Now start the server with the public key:
- Python
- TypeScript
AUTH_PUBLIC_KEY_FILE=keys/public.pem uv run server.py
npm run build
AUTH_PUBLIC_KEY_FILE=keys/public.pem node build/index.js
In a second terminal, check the metadata document. It names this server and its authorization server:
curl -s http://127.0.0.1:8790/.well-known/oauth-protected-resource/mcp
{"resource":"http://127.0.0.1:8790/mcp","authorization_servers":["https://auth.example.com/"],"scopes_supported":["mcp:tools"],"bearer_methods_supported":["header"]}
Then send an MCP request with no token. The response is the challenge a client uses to start sign-in:
curl -i -X POST http://127.0.0.1:8790/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
HTTP/1.1 401 Unauthorized
...
www-authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8790/.well-known/oauth-protected-resource/mcp"
...
{"error": "invalid_token", "error_description": "Authentication required"}
4. Test it with MCP Inspector
The MCP Inspector can send the token in a header. Its command-line mode lists the tools:
npx @modelcontextprotocol/inspector@2.11.0 --cli --transport http \
--server-url http://127.0.0.1:8790/mcp \
--header "Authorization: Bearer $TOKEN" \
--protocol-era modern --method tools/list
You should see add_numbers and whoami in the output. Run the same command with --protocol-era legacy to check the older handshake too. Then call whoami:
npx @modelcontextprotocol/inspector@2.11.0 --cli --transport http \
--server-url http://127.0.0.1:8790/mcp \
--header "Authorization: Bearer $TOKEN" \
--protocol-era modern --method tools/call --tool-name whoami
The output is the result shown at the top of this guide. If you leave out the --header line, the Inspector doesn't send a token. It treats the 401 as an OAuth challenge and, in a shell with no terminal attached, exits with auth_required.
5. Connect a client that signs users in
A client that supports remote servers with OAuth follows the same steps you just tested by hand. It reads resource_metadata from the 401, fetches the metadata, finds your authorization server, and opens a browser so the user can sign in. It sends your server's URL as the resource parameter on the authorization and token requests, as defined in RFC 8707. The spec says clients MUST send that parameter, so the token they get is for your server and no other.
Client registration
Before sign-in, the client needs a client ID from your authorization server. The spec lists three ways to get one:
- Client ID Metadata Documents: the client's
client_idis an HTTPS URL that points to a JSON document describing the client. The spec calls this the most common case and asks authorization servers and clients to support it. - Pre-registration: the client already has a client ID for your server, issued through a setup page or another arrangement.
- Dynamic Client Registration: the client registers itself at the authorization server's
registration_endpoint. The spec deprecates this for new implementations and keeps it for older authorization servers.
Your MCP server takes no part in registration. Your authorization server handles that, along with the sign-in itself. For what users see during sign-in, read Connect to OAuth servers. For apps that support remote servers, see MCP clients.
Common problems
401 with error="invalid_token", error_description="Authentication required"
The request had no Authorization header, or the header didn't start with Bearer . Send Authorization: Bearer <token> on every request. The spec requires the header on every HTTP request, and tokens can't go in the query string. The TypeScript server reports the same case as Missing Authorization header.
Rejected access token: Audience doesn't match (Python) or unexpected "aud" claim value (TypeScript, in the server log)
The token was issued for a different resource. The usual causes are that the client didn't send the resource parameter, or that it sent a different URL, such as localhost for 127.0.0.1, or a URL without /mcp. Request the token with resource set to the exact URL of your MCP endpoint.
Rejected access token: Invalid issuer (Python) or unexpected "iss" claim value (TypeScript)
The token's iss doesn't match AUTH_ISSUER character for character. A trailing slash counts. Set AUTH_ISSUER to the issuer your authorization server puts in its tokens.
Rejected access token: Signature has expired (Python) or "exp" claim timestamp check failed (TypeScript)
The token is past its expiry time. The client should refresh it or sign the user in again.
403 with error="insufficient_scope"
The token is valid for this server, but it doesn't carry mcp:tools. Ask for that scope in the authorization request. The spec says clients should use the scope from the 401 challenge when it's present, and otherwise the scopes in the metadata.
Set AUTH_PUBLIC_KEY_FILE to the PEM public key that signs your access tokens.
The server started without the key file. Set AUTH_PUBLIC_KEY_FILE to the path of the public key, as in step 3.
FAQ
Do local stdio MCP servers need OAuth?
No. The spec says authorization is for HTTP-based transports, and that stdio servers should get their credentials from the environment. The server in the build guide, which runs over stdio, needs no sign-in.
Does my MCP server issue the access tokens?
No. In the spec's terms, an MCP server is an OAuth 2.1 resource server: it accepts and checks tokens. The authorization server signs users in and issues the tokens. That can be a product you run yourself or an identity provider.
Can my server pass the user's token on to another API?
No. The spec says an MCP server MUST NOT pass through the token it received from the client. If your server calls another API, it needs its own credential for that API.
Does every request need a token?
Yes. The spec requires the Authorization header on every HTTP request from the client, and it doesn't allow tokens in the URL query string.
- Test with MCP InspectorUse the Inspector web interface and CLI to debug tools over HTTP.
- Deploy a remote MCP serverHost the server at a public HTTPS address that clients can reach.
- Connect to OAuth serversWhat the user sees when a remote server asks them to sign in.
- Common MCP vulnerabilitiesToken passthrough, confused deputy and other attacks on servers and clients.
- MCP security best practicesA checklist for builders and users of MCP servers.