Configure Bearer auth in MCP server
With the latest MCP specification, your MCP server acts as a Resource Server that validates access tokens for protected resources. This page covers the token verifier half of MCP Auth; Configure MCP Auth covers the configuration and metadata half.
The Bearer auth middleware itself comes from the MCP SDK: requireBearerAuth, available both fetch-native (from @modelcontextprotocol/server) and as framework adapters (e.g. from @modelcontextprotocol/express). What MCP Auth brings is the token verifier: the MCPAuth instance implements the SDK's OAuthTokenVerifier interface, and mcpAuth.getBearerAuthOptions() bundles everything into the SDK's BearerAuthOptions:
verifier: theMCPAuthinstance itself, verifying JWT access tokens against the trusted authorization server's JWKSresourceMetadataUrl: the RFC 9728 metadata URL, so theWWW-Authenticatechallenge on401responses points clients at your resource metadatarequiredScopes: the scopes you require for the endpoint, passed through
Protect your MCP endpoint
- Fetch-native (Cloudflare Workers, Deno, Bun, Node.js)
- Express (Node.js)
import { requireBearerAuth } from '@modelcontextprotocol/server';
import { MCPAuth } from 'mcp-auth';
const mcpAuth = new MCPAuth({
/* ... */
});
const gate = requireBearerAuth(mcpAuth.getBearerAuthOptions({ requiredScopes: ['read', 'write'] }));
export default {
async fetch(request: Request): Promise<Response> {
// ... serve the OAuth discovery documents for `/.well-known/` paths
const auth = await gate(request);
if (auth instanceof Response) {
// The token is missing or invalid: a `401` response with a `WWW-Authenticate` challenge
return auth;
}
// The token is valid: `auth` carries the verified auth info
return handler.fetch(request, { authInfo: auth });
},
};import { requireBearerAuth } from '@modelcontextprotocol/express';
import { MCPAuth } from 'mcp-auth';
const mcpAuth = new MCPAuth({
/* ... */
});
app.all(
'/mcp',
requireBearerAuth(mcpAuth.getBearerAuthOptions({ requiredScopes: ['read', 'write'] })),
(request, response) => {
// The token is valid: `request.auth` carries the verified auth info
}
);Both requireBearerAuth variants accept the same options type, so getBearerAuthOptions works with either. Endpoints with different scope requirements call getBearerAuthOptions once each.
For every request, the middleware verifies:
- Signature: the JWT is verified against the JWKS of the trusted authorization server
- Issuer (
iss): must match the configured authorization server - Audience (
aud): must match the configuredresourceidentifier - Expiration (
exp) and other standard time claims - Scopes: the token must include all
requiredScopes(if provided)
The MCP specification requires access tokens to be bound to the resource they are issued for (RFC 8707). MCP Auth always validates the aud claim against your resource identifier. There is no opt-out and no override, so your provider must issue audience-bound access tokens. Tokens are also required to carry a sub claim (per RFC 9068), so the verified auth info always has a subject.
In OAuth 2.0, scopes are the primary mechanism for permission control. A valid token with the correct audience does NOT guarantee the user has permission to perform an action: authorization servers may issue tokens with an empty or limited scope.
Always use requiredScopes to enforce that the token contains the necessary permissions for each operation. Never assume a valid token implies full access.
Enforce scopes per tool
On top of the endpoint-level requiredScopes, you can enforce scopes for individual tools with getAuthInfo:
import { getAuthInfo } from 'mcp-auth';
server.registerTool(
'delete-note',
{
description: 'Delete a note by ID',
inputSchema: z.object({ id: z.string() }),
},
({ id }, context) => {
// Throws unless the token has the `write` scope; the MCP SDK surfaces the error to the
// model as a tool error result (`isError: true`) with an `insufficient_scope: ...` message
const { subject } = getAuthInfo(context, { requiredScopes: ['write'] });
// ... delete the note owned by `subject`
}
);
The returned McpAuthInfo object is the SDK's AuthInfo extended with the guarantees MCP Auth provides after verification:
issuer: the verifiedissclaim, always the configured trusted authorization serversubject: the verifiedsubclaim, typically the user IDclaims: the full verified JWT payload, for access to any custom claimsscopes: parsed from thescopeclaim (space-separated string) or thescopesclaim (array)clientId: from theclient_idclaim, falling back toazptoken: the raw access token, handy for calling downstream APIs on the user's behalf
Customize JWT verification
MCP Auth uses the jose library to verify JWTs. Use jwtVerifyOptions to pass options through to jose's jwtVerify function for advanced tuning:
const mcpAuth = new MCPAuth({
protectedResourceMetadata: {
/* ... */
},
jwtVerifyOptions: {
clockTolerance: 60, // Allow a 60 seconds clock skew
requiredClaims: ['email'], // Reject tokens without these claims
},
});
The issuer and audience options are excluded: they always derive from the protected resource metadata declaration and cannot be overridden.
Verify opaque tokens (custom verification)
MCPAuth verifies JWT access tokens against your provider's JWKS. Some authorization servers issue opaque access tokens instead: random strings with nothing to verify locally. The two halves of MCP Auth are decoupled, so this case is covered by bringing your own verifier: implement the SDK's OAuthTokenVerifier against your server's token introspection endpoint (RFC 7662), and keep using the metadata half. The discovery documents, the challenge URL, and getAuthInfo() all work unchanged.
import {
OAuthError,
OAuthErrorCode,
requireBearerAuth,
type OAuthTokenVerifier,
} from '@modelcontextprotocol/server';
import { MCPAuth, type McpAuthInfo } from 'mcp-auth';
const issuer = 'https://auth.example.com/oidc';
const resource = 'https://api.example.com/mcp';
// The metadata half works exactly as before
const mcpAuth = new MCPAuth({
protectedResourceMetadata: {
resource,
authorizationServer: { issuer, type: 'oidc' },
scopesSupported: ['read:notes'],
},
});
const introspectionEndpoint = 'https://auth.example.com/oidc/token/introspection';
// Most servers require a confidential client (e.g. a machine-to-machine app) to
// introspect tokens issued to other clients
const clientId = 'your-m2m-client-id';
const clientSecret = 'your-m2m-client-secret';
const introspectionVerifier: OAuthTokenVerifier = {
async verifyAccessToken(token): Promise<McpAuthInfo> {
const response = await fetch(introspectionEndpoint, {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded',
authorization: `Basic ${btoa(`${clientId}:${clientSecret}`)}`,
},
body: new URLSearchParams({ token, token_type_hint: 'access_token' }),
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
/*
* A plain `Error`, not an `OAuthError`: the SDK answers 500. The token could not be
* verified, which is different from being invalid; a 401 would send a client with a
* perfectly fine token into a pointless re-authorization.
*/
throw new Error(`Introspection request failed with status ${response.status}.`);
}
const data = (await response.json()) as McpAuthInfo['claims'];
// The MCP spec still requires these checks; introspection does not exempt them
if (data.active !== true) {
throw new OAuthError(OAuthErrorCode.InvalidToken, 'The token is not active.');
}
if (!(Array.isArray(data.aud) ? data.aud : [data.aud]).includes(resource)) {
throw new OAuthError(OAuthErrorCode.InvalidToken, 'The token audience does not match.');
}
if (typeof data.iss === 'string' && data.iss !== issuer) {
throw new OAuthError(OAuthErrorCode.InvalidToken, 'The token issuer is not trusted.');
}
if (typeof data.sub !== 'string' || typeof data.exp !== 'number') {
throw new OAuthError(OAuthErrorCode.InvalidToken, 'The token has no `sub` or `exp`.');
}
// The `McpAuthInfo` shape, so `getAuthInfo()` in tool callbacks works unchanged
return {
token,
issuer,
subject: data.sub,
clientId: typeof data.client_id === 'string' ? data.client_id : '',
scopes: typeof data.scope === 'string' ? data.scope.split(' ').filter(Boolean) : [],
expiresAt: data.exp,
claims: data,
};
},
};
// Only the gate changes; the discovery documents still come from `mcpAuth`
const gate = requireBearerAuth({
verifier: introspectionVerifier,
resourceMetadataUrl: mcpAuth.resourceMetadataUrl,
requiredScopes: ['read:notes'],
});
A few things to know:
- The endpoint: some servers advertise it as
introspection_endpointin their metadata, others keep it off the public discovery document entirely (e.g. an internal admin API). Configure whatever yours is. - The credentials: most servers only let authenticated confidential clients introspect tokens issued to other clients; some deployments protect the endpoint at the network level instead. Check your server's policy.
- The cost: every request is an introspection round-trip. That is also the point: revoked tokens are rejected immediately. Add caching only if you accept the revocation delay.
Error handling
Token verification failures are thrown as the SDK's OAuthError (with code invalid_token), which the SDK bearer auth helpers map to a 401 response with a WWW-Authenticate challenge. Configuration and metadata discovery failures are thrown as MCPAuthConfigError / MCPAuthAuthServerError, which the SDK maps to a 500 response: the token could not be verified, which is different from being invalid.