Skip to main content

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: the MCPAuth instance itself, verifying JWT access tokens against the trusted authorization server's JWKS
  • resourceMetadataUrl: the RFC 9728 metadata URL, so the WWW-Authenticate challenge on 401 responses points clients at your resource metadata
  • requiredScopes: the scopes you require for the endpoint, passed through

Protect your MCP endpoint

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 });
  },
};

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 configured resource identifier
  • Expiration (exp) and other standard time claims
  • Scopes: the token must include all requiredScopes (if provided)
Audience validation is always on

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.

Always Validate Scopes

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 verified iss claim, always the configured trusted authorization server
  • subject: the verified sub claim, typically the user ID
  • claims: the full verified JWT payload, for access to any custom claims
  • scopes: parsed from the scope claim (space-separated string) or the scopes claim (array)
  • clientId: from the client_id claim, falling back to azp
  • token: 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_endpoint in 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.