--- shape: how-to description: 'Require a bearer token on a server you run: verification, protected-resource metadata, and per-operation scopes.' --- # Require authorization Protecting a server you run → this page. Signing a user in from a client you build → [Authenticate a user with OAuth](../clients/oauth.md). No user present → [Authenticate without a user](../clients/machine-auth.md). ## 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](./express.md). ```ts source="../../examples/guides/serving/authorization.examples.ts#requireBearerAuth_basic" 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, requireScopes } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; const mcpServerUrl = new URL('https://api.example.com/mcp'); const verifier: OAuthTokenVerifier = { verifyAccessToken }; const auth = requireBearerAuth({ verifier, requiredScopes: ['mcp'], resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl), expectedResource: 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. ::: info 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. ::: ## Require a bearer token on a web-standard host On hosts whose HTTP surface is a `fetch(request)` handler — Cloudflare Workers, Deno, Bun, Hono — the gate is `requireBearerAuth` from `@modelcontextprotocol/server`: no framework, only web-standard `Request` and `Response`. ```ts source="../../examples/guides/serving/authorization.web.examples.ts#requireBearerAuth_webStandard" const gate = requireBearerAuth({ verifier, requiredScopes: ['mcp'], expectedResource: mcpServerUrl }); const handler = createMcpHandler(buildServer); export default { async fetch(request: Request): Promise { const auth = await gate(request); if (auth instanceof Response) return auth; return handler.fetch(request, { authInfo: auth }); } }; ``` The gate resolves to the verified `AuthInfo` — pass it to the handler as `{ authInfo }` and handlers read it as `ctx.http.authInfo` — or to the ready-to-return challenge `Response`. Status codes, error bodies, and the `WWW-Authenticate` challenge (including `resourceMetadataUrl`) are identical to the Express middleware: both are adapters over one core, so a verifier written for one serves the other unchanged. ## Verify tokens your way `verifyAccessToken` is the one function you supply: take the raw token string, return an `AuthInfo`. Local JWT verification, [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) introspection, or a call to your identity provider all fit behind it. ```ts source="../../examples/guides/serving/authorization.examples.ts#tokenVerifier_basic" async function verifyAccessToken(token: string): Promise { const payload = await verifyJwt(token); // `aud` is one value, a list, or absent: report the entry on this server's origin, if any, and let `expectedResource` compare it. const audience = [payload.aud ?? []].flat().find(aud => URL.canParse(aud) && new URL(aud).origin === mcpServerUrl.origin); const resource = audience ? new URL(audience) : undefined; return { token, clientId: payload.sub, scopes: payload.scopes, expiresAt: payload.exp, resource }; } ``` 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. ::: `expectedResource` makes `requireBearerAuth` accept only tokens issued for this resource (the token's audience). Set it to the value your authorization server puts into tokens meant for this server, usually the server's URL. When it is set, `requireBearerAuth` accepts a token only if your verifier reports that value in `AuthInfo.resource` (one trailing slash aside) and answers `401 invalid_token` for a token reported for another value or for none, so populate `resource` from the JWT `aud` claim or the introspection response's `aud` field. `aud` can be a list or absent: report this server's entry, and leave `resource` unset when there is none. When `expectedResource` is not set, `AuthInfo.resource` is not compared with anything. With Express, `@modelcontextprotocol/express` has to be upgraded together with `@modelcontextprotocol/server`: `@modelcontextprotocol/express` 2.0.1 does not pass `expectedResource` on, so nothing is compared. Its options type does not have the option, so TypeScript reports an `expectedResource` written in a call to its `requireBearerAuth` as an error. ## Publish protected resource metadata `mcpAuthMetadataRouter` serves the [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) protected resource metadata document that the `401` challenge points at. `oauthMetadata` is your authorization server's own RFC 8414 metadata document. ```ts source="../../examples/guides/serving/authorization.examples.ts#metadataRouter_basic" 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. On a web-standard host, `oauthMetadataResponse` from `@modelcontextprotocol/server` serves the same two documents from a `fetch(request)` handler — it returns the matched document `Response` (with permissive CORS and `405` handling) or `undefined` to fall through to your own routing: ```ts source="../../examples/guides/serving/authorization.examples.ts#oauthMetadataResponse_webStandard" import { oauthMetadataResponse } from '@modelcontextprotocol/server'; async function webStandardFetch(request: Request): Promise { return oauthMetadataResponse(request, { oauthMetadata, resourceServerUrl: mcpServerUrl }) ?? serveMcp(request); } ``` ## 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. ```ts source="../../examples/guides/serving/authorization.examples.ts#authInfo_handler" 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](./stdio.md), 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-operation scopes `requiredScopes` gates the whole endpoint. For scope step-up on an individual tool call, resource read, or prompt retrieval, set `scopeChallenge` on its registration — no handler or transport configuration is needed. The callback receives the full parsed request and verified `authInfo`. Return `undefined` to continue, or return the exact, complete scope set to send `403 insufficient_scope` before invocation or SSE. Throwing or rejecting fails closed. The challenge uses the same OAuth `insufficient_scope` JSON body and `WWW-Authenticate` formatter as `requireBearerAuth`'s own `403` answer. Its `resource_metadata` parameter comes from the verified `AuthInfo`: the gate stamps its configured `resourceMetadataUrl` onto the `AuthInfo` it returns, so the metadata URL is configured exactly once — on `requireBearerAuth`. Without a stamped value the parameter falls back to the well-known location for the token's RFC 8707 `resource` identifier, or is omitted. Use `requireScopes` for a static exact all-of check. Use a callback when the required scope set depends on the request: ```ts source="../../examples/guides/serving/authorization.examples.ts#perOperationScopes_challenge" server.registerTool('purge-notes', { scopeChallenge: requireScopes('notes:write') }, async () => ({ content: [{ type: 'text', text: 'All notes deleted' }] })); server.registerResource('private-notes', 'notes://private', { scopeChallenge: requireScopes('notes:read') }, async uri => ({ contents: [{ uri: uri.href, text: 'Private notes' }] })); server.registerPrompt('summarize-notes', { scopeChallenge: requireScopes('notes:read') }, async () => ({ messages: [{ role: 'user', content: { type: 'text', text: 'Summarize my private notes' } }] })); server.registerTool( 'read-repository', { inputSchema: z.object({ visibility: z.enum(['public', 'private']) }), scopeChallenge: ({ request, authInfo }) => { const visibility = (request.params as { arguments?: { visibility?: unknown } }).arguments?.visibility; if (visibility !== 'public' && visibility !== 'private') return; const scopes = visibility === 'private' ? (['repo:read'] as const) : (['public_repo'] as const); return scopes.every(scope => authInfo?.scopes.includes(scope)) ? undefined : { scopes, errorDescription: `${visibility} repository access is required` }; } }, async ({ visibility }) => ({ content: [{ type: 'text', text: `Read ${visibility} repository` }] }) ); ``` Scope interpretation belongs to your callback; the SDK does not infer hierarchies, alternatives, or missing scopes. Challenged primitives remain visible in their list operations. ::: warning The callback runs before the primitive's input schema is validated or transformed. Its `request` contains the JSON-parsed wire values, so dynamic authorization should validate or canonicalize any value whose schema changes its meaning before handler invocation. Scope names must follow the OAuth `scope-token` grammar; `errorDescription`, when provided, must follow RFC 6750's `error-description` grammar. ::: ## Recap - `requireBearerAuth` from `@modelcontextprotocol/server` is the same gate for web-standard `fetch` hosts; the Express middleware adapts the same core. - `requireBearerAuth` plus a `verifyAccessToken` you 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 a `requiredScopes` entry gets `403 insufficient_scope`; both carry a `WWW-Authenticate: Bearer` challenge. - `mcpAuthMetadataRouter` publishes the RFC 9728 document that challenge points at, plus a mirror of the AS metadata. - Verified auth flows `req.auth` → `ctx.http.authInfo`; per-operation callbacks can trigger HTTP `403` scope step-up before invocation, advertising the metadata URL the gate stamped onto `AuthInfo`. - The v1 Authorization Server helpers are frozen in `@modelcontextprotocol/server-legacy/auth`.