Require authorization
Protecting a server you run → this page. Signing a user in from a client you build → Authenticate a user with OAuth. No user present → Authenticate without a user.
Require a bearer token
Your MCP server is an OAuth resource server: it verifies access tokens that an authorization server issued, and it never issues them. requireBearerAuth from @modelcontextprotocol/express is that whole gate — build it from a verifier and mount it in front of the /mcp route from the Express recipe.
import type { OAuthTokenVerifier } from '@modelcontextprotocol/express';
import {
createMcpExpressApp,
getOAuthProtectedResourceMetadataUrl,
mcpAuthMetadataRouter,
requireBearerAuth
} from '@modelcontextprotocol/express';
import { toNodeHandler } from '@modelcontextprotocol/node';
import type { AuthInfo, OAuthMetadata } from '@modelcontextprotocol/server';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
const mcpServerUrl = new URL('https://api.example.com/mcp');
const verifier: OAuthTokenVerifier = { verifyAccessToken };
const auth = requireBearerAuth({
verifier,
requiredScopes: ['mcp'],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
});
const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] });
const node = toNodeHandler(createMcpHandler(buildServer));
app.all('/mcp', auth, (req, res) => void node(req, res, req.body));A request with a missing, malformed, or expired token gets 401 with the OAuth error code invalid_token. A valid token missing one of requiredScopes gets 403 with insufficient_scope. Both responses carry a WWW-Authenticate: Bearer … challenge whose resource_metadata parameter is the URL you passed — that challenge is what starts a client's OAuth flow.
Coming from v1?
The Authorization Server helpers (mcpAuthRouter, ProxyOAuthServerProvider, …) are frozen in @modelcontextprotocol/server-legacy/auth. Use a dedicated identity provider for new servers; this page only covers the resource-server half.
Verify tokens your way
verifyAccessToken is the one function you supply: take the raw token string, return an AuthInfo. Local JWT verification, RFC 7662 introspection, or a call to your identity provider all fit behind it.
async function verifyAccessToken(token: string): Promise<AuthInfo> {
const payload = await verifyJwt(token);
return { token, clientId: payload.sub, scopes: payload.scopes, expiresAt: payload.exp };
}Throw an OAuthError with OAuthErrorCode.InvalidToken (both from @modelcontextprotocol/server) for a token you reject, and requireBearerAuth turns it into the 401 challenge. Any other exception comes back as 500 server_error.
WARNING
requireBearerAuth also answers 401 invalid_token for a token whose expiresAt is unset. Always populate it — from the JWT exp claim or the introspection response's exp field.
Publish protected resource metadata
mcpAuthMetadataRouter serves the RFC 9728 protected resource metadata document that the 401 challenge points at. oauthMetadata is your authorization server's own RFC 8414 metadata document.
app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpServerUrl }));The router mounts two well-known routes: /.well-known/oauth-protected-resource/mcp — the path-aware RFC 9728 location, the same string getOAuthProtectedResourceMetadataUrl(mcpServerUrl) put into the challenge — and /.well-known/oauth-authorization-server, a mirror of oauthMetadata for clients that probe your origin directly. An unauthenticated client follows 401 → resource_metadata → authorization_servers to find your AS, obtains a token, and retries.
Read the caller in your handlers
requireBearerAuth attaches the verified AuthInfo to req.auth, toNodeHandler forwards it, and tool handlers inside buildServer read it as ctx.http.authInfo — the exact object your verifier returned.
server.registerTool('whoami', { description: 'Report the authenticated caller' }, async ctx => {
const caller = ctx.http?.authInfo;
return { content: [{ type: 'text', text: `${caller?.clientId} [${caller?.scopes.join(' ')}]` }] };
});ctx.http is undefined when the same server runs over stdio, so guard the read if your server serves both transports.
TIP
The per-request factory itself receives the same value as ctx.authInfo, so it can register a different tool set per caller before any handler runs.
Enforce per-tool scopes
requiredScopes gates the whole endpoint. For a scope only some tools need, check inside the handler — the handler is the only place that knows which tool is executing.
server.registerTool('purge-notes', { description: 'Delete every note' }, async ctx => {
if (!ctx.http?.authInfo?.scopes.includes('notes:write')) {
return { content: [{ type: 'text', text: 'insufficient_scope: purge-notes requires notes:write' }], isError: true };
}
return { content: [{ type: 'text', text: 'All notes deleted' }] };
});A caller holding only mcp gets an ordinary tool result with isError: true, so the model reads the refusal and moves on instead of losing the connection.
INFO
Responding 403 insufficient_scope at the HTTP layer instead triggers the client transport's automatic scope step-up (SEP-2350) — see Authenticate a user with OAuth.
Recap
requireBearerAuthplus averifyAccessTokenyou write turn an Express-mounted MCP route into an OAuth resource server; the SDK never issues tokens.- Missing, invalid, or expired tokens get
401 invalid_token; a token missing arequiredScopesentry gets403 insufficient_scope; both carry aWWW-Authenticate: Bearerchallenge. mcpAuthMetadataRouterpublishes the RFC 9728 document that challenge points at, plus a mirror of the AS metadata.- Verified auth flows
req.auth→ctx.http.authInfo; per-tool scopes are a check inside the handler that returnsisError: true. - The v1 Authorization Server helpers are frozen in
@modelcontextprotocol/server-legacy/auth.