MCP Servers
MCP Authentication
Securing MCP servers with bearer tokens, identity proxies, and OAuth 2.1
Last verified 2026-09-24
Summary
servers require authentication matching their . Bearer suffice for small, controlled environments. is mandated for public servers, a requirement in place since the 2025-03-26 revision introduced MCP's authorization framework. Three approaches by complexity: bearer tokens (simple, environment-controlled), identity proxies (add authorization layer), and OAuth 2.1 (standards-based, public-scale). Even internal servers need basic defense.
Use Case Approach
────────────────── ──────────────────
Local development Bearer token
Single user/team Bearer token
Internal network Identity proxy
Public servers OAuth 2.1- Bearer token: environment variable, timing-safe comparison
- Identity proxy: add authorization layer between client and server
- OAuth 2.1: standards-based, public-scale, with
- Never expose tokens in logs or responses
- Use HTTPS for all network transports
An unauthenticated MCP server is an unauthenticated . Any tool it exposes - including destructive ones - is callable by anyone who can reach the endpoint. Authentication in MCP is not optional for any server that handles real data or performs real actions.
The current 2026-07-28 MCP specification requires OAuth 2.1 for publicly accessible protected servers and hardens the predecessor model. Servers used only within a controlled environment (single user, stdio, internal network) can use simpler approaches. This page covers all three patterns in order of complexity.
Approach 1: Bearer Token
The simplest approach for servers used by a small number of clients in a controlled environment. Generate a long random token, store it in an environment variable on both the server and the client, and reject requests that do not present it.
// middleware/auth.ts
import type { Request, Response, NextFunction } from "express";
export function bearerTokenAuth(requiredToken: string) {
return (req: Request, res: Response, next: NextFunction): void => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
res.status(401).json({
error: "unauthorized",
error_description: "Missing Bearer token in Authorization header",
});
return;
}
const token = authHeader.slice(7);
// Use timing-safe comparison to prevent timing attacks
const expected = Buffer.from(requiredToken);
const received = Buffer.from(token);
if (expected.length !== received.length || !crypto.timingSafeEqual(expected, received)) {
res.status(401).json({
error: "unauthorized",
error_description: "Invalid token",
});
return;
}
next();
};
}// Apply to the MCP endpoint
app.use("/mcp", bearerTokenAuth(process.env.MCP_TOKEN!));
app.post("/mcp", mcpHandler);The client provides the token in the Authorization header:
POST /mcp HTTP/1.1
Authorization: Bearer mcp_live_Xk9p2QrT8nV4mB7wL1sE6
Content-Type: application/jsonWhen to use bearer tokens: internal tools, developer workstations, CI pipelines, single-tenant servers where the operator controls both ends of the connection.
STDIO transport SHOULD NOT follow the OAuth authorization flow at all - the MCP 2026-07-28
specification says implementations using STDIO should instead retrieve credentials from the
environment, which is exactly the bearer-token-via-env-var pattern above. Reach for OAuth 2.1 only
once a server is reachable over an HTTP-based transport. Source:
https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization
Approach 2: Identity Proxy
An identity proxy sits in front of your MCP server and handles authentication on its behalf. The proxy validates the incoming credentials (OAuth token, API key, session cookie) and forwards requests to your server with a trusted identity header:
┌──────────┐ Bearer token ┌───────────┐ X-User-Id: usr_123 ┌───────────┐
│ Client │ ──────────────────── ▶ │ Proxy │ ────────────────────── ▶ │ MCP Server│
└──────────┘ └───────────┘ └───────────┘Your MCP server trusts the proxy and reads identity from the forwarded header. It never handles token validation itself.
// In your MCP server - trust the proxy's identity assertion
app.post("/mcp", (req, res, next) => {
const userId = req.headers["x-user-id"];
const userScopes = req.headers["x-user-scopes"];
if (!userId) {
res.status(401).json({ error: "Missing identity header from proxy" });
return;
}
// Attach to request context for use in tool handlers
(req as AuthedRequest).userId = userId as string;
(req as AuthedRequest).scopes = (userScopes as string)?.split(",") ?? [];
next();
});Only accept identity headers from a trusted proxy IP range. If any client can send X-User-Id: admin, your authorization is meaningless. Configure your server to only accept these headers from
the proxy's known IP addresses.
Approach 3: OAuth 2.1 with PKCE
OAuth 2.1 with PKCE is mandatory for publicly accessible MCP servers, a requirement in place since the 2025-03-26 revision introduced MCP's authorization framework. It is the only authentication method that supports delegated authorization - where an agent acts on behalf of a user without the user handing over their credentials.
The protocol-level model - protected-resource metadata discovery, authorization-server discovery, client registration options, and token audience binding - lives in MCP Authorization. This section is the server-implementer view: the endpoints you expose, PKCE, token validation, scope enforcement, and the flow. Read the canonical page first if you need the full OAuth 2.1 discovery and registration flow; use this page to implement it.
Current 2026-07-28 behavior: clients must validate the authorization response's iss parameter
per RFC 9207 (SEP-2468) and must key stored client credentials by issuer, using them only with the
authorization server that issued them and re-registering when an issuer changes (SEP-2352). RFC
7591 dynamic client registration is Deprecated in favor of , and where
you still accept the application_type field becomes required (SEP-837). The server-side work
on this page is unaffected - you validate a bearer token per request either way, which is exactly
what the stateless core assumes now that sessions are gone. See MCP
Authorization for the full list.
MCP clients MUST implement Resource Indicators for OAuth 2.0 (RFC 8707): the resource
parameter identifying your server's canonical URI (for example https://billing-mcp.example.com)
is required in both the authorization request and the token request, not only reflected back
in the aud claim you validate below. A client that omits it is spec-non-conformant; a server
cannot add the parameter on the client's behalf, but audience validation on the token you receive
is how you catch a client that skipped it. Source:
https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization
Required .well-known Endpoints
Your server must expose two discovery endpoints so clients can discover your authorization server (the MCP Authorization and Protected Resource Metadata pages cover the discovery protocol and full field set):
GET /.well-known/oauth-protected-resource - declares that this resource requires OAuth and points to the authorization server:
{
"resource": "https://billing-mcp.example.com",
"authorization_servers": ["https://auth.example.com"],
"bearer_methods_supported": ["header"],
"scopes_supported": ["billing:read", "billing:write", "billing:admin"]
}GET /.well-known/oauth-authorization-server - describes the authorization server's capabilities (if you are running your own):
{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/oauth/authorize",
"token_endpoint": "https://auth.example.com/oauth/token",
"response_types_supported": ["code"],
"code_challenge_methods_supported": ["S256"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"scopes_supported": ["billing:read", "billing:write", "billing:admin"]
}PKCE Flow
PKCE (Proof Key for Code Exchange) prevents authorization code interception. The client generates a random code_verifier, hashes it to produce a code_challenge, and sends only the hash to the authorization server. When it later exchanges the code for a token, it proves it holds the original verifier.
S256 only. The plain method is insecure and must not be used.
// Client-side PKCE generation (for reference - this runs in the MCP client)
import { createHash, randomBytes } from "node:crypto";
function generatePKCE() {
const codeVerifier = randomBytes(32).toString("base64url");
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
return { codeVerifier, codeChallenge };
}
// Authorization URL construction
const { codeVerifier, codeChallenge } = generatePKCE();
const authUrl = new URL("https://auth.example.com/oauth/authorize");
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", "mcp-client-id");
authUrl.searchParams.set("redirect_uri", "http://localhost:8080/callback");
authUrl.searchParams.set("scope", "billing:read billing:write");
authUrl.searchParams.set("code_challenge", codeChallenge);
authUrl.searchParams.set("code_challenge_method", "S256");
authUrl.searchParams.set("state", randomBytes(16).toString("hex")); // CSRF protection
authUrl.searchParams.set("resource", "https://billing-mcp.example.com"); // RFC 8707 - requiredThe client repeats the same resource parameter in the token request that exchanges the code for an access token. Omitting it on either request is a protocol violation, not an optional hardening step.
JWT Validation
Your MCP server validates the Bearer token on each request. For tokens, validation must check all of: signature, issuer, audience, and expiry.
import { jwtVerify, createRemoteJWKSet } from "jose";
const JWKS = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json"));
export async function validateToken(token: string): Promise<{ userId: string; scopes: string[] }> {
const { payload } = await jwtVerify(token, JWKS, {
issuer: "https://auth.example.com", // Validates iss claim
audience: "https://billing-mcp.example.com", // Validates aud claim
// exp claim is validated automatically by jose
});
const userId = payload.sub;
if (!userId) {
throw new Error("Token missing sub claim");
}
const scopes = ((payload.scope as string) ?? "").split(" ").filter(Boolean);
return { userId, scopes };
}Use createRemoteJWKSet with caching (the default) rather than a static public key. This allows the authorization server to rotate keys without requiring you to redeploy.
Scope Enforcement Per Tool
After validating the token, enforce scopes at the tool level:
function requireScope(scopes: string[], required: string): void {
if (!scopes.includes(required)) {
throw new Error(
`Insufficient scope. Required: ${required}. ` +
`Granted: ${scopes.join(", ") || "none"}. ` +
`Request a new token with the ${required} scope.`,
);
}
}
// In your tool handler
async (params, context) => {
requireScope(context.scopes, "billing:write");
// proceed with creating the invoice
};Throwing from a scope check causes the tool to return an MCP error. Return it as an isError: true response instead for better agent recovery:
async (params, context) => {
if (!context.scopes.includes("billing:write")) {
return {
isError: true,
content: [
{
type: "text",
text:
"Insufficient permissions. The billing:write scope is required to create invoices. " +
"Request a new authorization token with the billing:write scope.",
},
],
};
}
// proceed
};On-Behalf-Of (OBO) Pattern
When your MCP server needs to call downstream APIs on behalf of the authenticated user, use the On-Behalf-Of flow to exchange the user's token for a token scoped to the downstream service:
async function getDownstreamToken(userToken: string, targetAudience: string): Promise<string> {
const response = await fetch("https://auth.example.com/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
subject_token: userToken,
subject_token_type: "urn:ietf:params:oauth:token-type:access_token",
audience: targetAudience,
scope: "stripe:read stripe:write",
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Token exchange failed: ${error.error_description}`);
}
const { access_token } = await response.json();
return access_token;
}This preserves the audit trail - the downstream API sees that the request is on_behalf_of the original user, rather than only your service identity.
Using withMcpAuth
The mcp-handler package provides a withMcpAuth wrapper that integrates OAuth validation directly into the Vercel route handler:
// app/mcp/route.ts
import { createMcpHandler, withMcpAuth } from "mcp-handler";
import { createServer } from "@/lib/billing-mcp/server";
const mcpHandler = createMcpHandler((req) =>
createServer({
stripeApiKey: process.env.STRIPE_API_KEY!,
databaseUrl: process.env.DATABASE_URL!,
// Pass the authenticated user from the request context
userId: (req as any).mcpAuth?.userId,
scopes: (req as any).mcpAuth?.scopes ?? [],
}),
);
async function validateAuth(token: string) {
const result = await validateToken(token);
return result; // { userId, scopes }
}
export const { GET, POST, DELETE } = withMcpAuth(mcpHandler, validateAuth);
export const maxDuration = 60;Five Security Mistakes
1. Token passthrough without validation. Forwarding the client's token directly to downstream services without validating it first. Always validate signature, issuer, audience, and expiry before trusting a token.
2. Missing audience claim validation. A token issued for https://other-service.example.com is not valid for your server. Without aud validation, tokens stolen from other services work against yours. Always pass your server's URL as the expected audience.
3. Wildcard redirect URIs. Registering https://myapp.example.com/* as a redirect URI allows an attacker to redirect the authorization code to any path on your domain. Register exact URIs only: https://myapp.example.com/auth/callback.
4. Missing CSRF protection on the callback. The state parameter in OAuth prevents CSRF attacks on the authorization flow. Verify that the state returned in the callback matches the state you sent. If it does not match, reject the response.
5. Storing tokens in plaintext. Access tokens and refresh tokens stored in plaintext in a database or log file are readable by anyone with database or log access. Encrypt tokens at rest. Never log token values, even in debug output.
// WRONG: logging token values
console.error(`Received token: ${token}`);
// CORRECT: log only non-sensitive metadata
console.error(`Received token: iss=${payload.iss} sub=${payload.sub} exp=${payload.exp}`);HTTPS is required for all OAuth flows. The code_challenge mechanism of PKCE protects the
authorization code in transit, but the access token itself is a bearer credential - anyone who
intercepts it can use it. Never run an OAuth-authenticated MCP server over plain HTTP in
production.
Related
- MCP Authorization - canonical protocol flow: protected-resource metadata, authorization-server discovery, client registration, and token audience binding
- Protected Resource Metadata - the
/.well-known/oauth-protected-resourcedocument your server publishes - OAuth 2.1 for Agents - the underlying grant flow