OAuth is disabled by default. Existing MCP configurations continue to work unchanged.
Overview
When OAuth is enabled, every HTTP request to the MCP server must include a valid JWT bearer token in theAuthorization header. The server validates the token using configured JWKS providers and enforces scope requirements at multiple levels. All scope enforcement happens at the HTTP transport level per the MCP specification, returning 403 Forbidden responses with WWW-Authenticate headers that enable step-up authorization.
Quick Start
Configuration Options
| Option | Description | Default |
|---|---|---|
oauth.enabled | Enable OAuth 2.1 / JWKS-based authentication for the MCP server | false |
oauth.authorization_server_url | Base URL of the OAuth 2.0 authorization server. Advertised via the RFC 9728 metadata endpoint so clients can discover authorization endpoints. | - |
oauth.scope_challenge_include_token_scopes | When true, includes the token’s existing scopes in the scope parameter of 403 responses. Works around MCP SDK scope accumulation bugs. See Scope Challenge Behavior. | false |
oauth.scopes.initialize | Scopes required for all HTTP requests (checked before JSON-RPC parsing). This is the baseline scope needed to establish an MCP connection. | [] |
oauth.scopes.tools_list | Scopes required for the tools/list MCP method. | [] |
oauth.scopes.tools_call | Scopes required for the tools/call MCP method (any tool invocation). Per-tool and built-in tool scopes are enforced additively. | [] |
oauth.scopes.execute_graphql | Scopes required to call the execute_graphql built-in tool. Additive to tools_call. Only relevant when enable_arbitrary_operations is true. | [] |
oauth.scopes.get_operation_info | Scopes required to call the get_operation_info built-in tool. Additive to tools_call. | [] |
oauth.scopes.get_schema | Scopes required to call the get_schema built-in tool. Additive to tools_call. Only relevant when expose_schema is true. | [] |
oauth.jwks | List of JWKS providers for JWT verification. Supports remote JWKS URLs or symmetric secrets. | [] |
JWKS Configuration
Theoauth.jwks array configures one or more JWKS providers for JWT verification. The MCP OAuth config uses the same JWKSConfiguration format as the router’s top-level authentication configuration, though it is configured independently. You can point the MCP JWKS to the same JWKS URL as your router authentication, or to a different identity provider entirely.
For the full JWKS configuration reference including all options (refresh_unknown_kid, allowed_use, etc.), see Router Authentication.
Remote JWKS URL
| Field | Description | Default |
|---|---|---|
url | URL of the JWKS endpoint | (required) |
audiences | Allowed JWT aud claim values | (any) |
algorithms | Allowed signing algorithms (RS256, ES256, PS256, EdDSA, etc.) | (all) |
refresh_interval | How often to refresh the JWKS key set | 1m |
Symmetric Secret
For development or testing, you can use a shared symmetric secret instead of a remote JWKS endpoint:Scope Enforcement
Scopes are enforced at multiple levels, all at the HTTP transport layer per the MCP specification. Each level is additive — a request must satisfy all applicable scope gates.Initialize Scopes (HTTP Level)
Thescopes.initialize list defines scopes required for every HTTP request to the MCP server. These are checked before the JSON-RPC payload is parsed, serving as the baseline authorization to establish an MCP connection.
Method-Level Scopes
Additional scopes can be required for specific MCP methods:initialize and method-level scopes are configured, the token must contain all of them. For example, calling tools/call requires both the initialize scopes and the tools_call scopes.
Scopes in the JWT must be provided as a space-separated string in the
scope claim (per OAuth 2.0 convention). Array-format scope claims are not supported.Built-in Tool Scopes
The MCP server provides three built-in tools:execute_graphql, get_operation_info, and get_schema. Each can have its own scope requirements, configured independently from tools_call:
tools_call — the token must satisfy both. If tools_call is empty, only the built-in tool scope is checked.
Scopes for execute_graphql are only relevant when enable_arbitrary_operations: true, and scopes for get_schema are only relevant when expose_schema: true. When the corresponding feature is disabled, the tool is not registered and its scopes are excluded from the scopes_supported metadata.
Per-Tool Scopes (from @requiresScopes)
When your GraphQL schema uses the @requiresScopes directive on fields, the MCP server automatically extracts scope requirements for each tool at startup. If a tool’s underlying GraphQL operation touches fields that require specific scopes, those scopes are enforced when the tool is called.
For example, if your schema defines:
getTopSecretFacts.graphql that queries topSecretFacts, calling that tool will require either read:fact OR read:all in addition to any initialize and tools_call scopes.
The @requiresScopes directive uses OR-of-AND semantics. When an operation touches multiple fields with @requiresScopes, the MCP server computes the combined scope requirement using the Cartesian product rules. For a full explanation of how scopes combine, see the @requiresScopes directive documentation.
Per-tool scopes are computed at startup (and on config reload), so they are enforced at the HTTP level with zero runtime overhead per request.
Runtime Scopes (execute_graphql)
When enable_arbitrary_operations is enabled, the execute_graphql tool allows AI models to craft custom GraphQL queries. Since the server cannot know which fields will be queried ahead of time, scope checking happens at request time by parsing the GraphQL query and extracting @requiresScopes requirements for the fields it references.
This runtime check uses the same OR-of-AND semantics and smart challenge selection as per-tool scopes. If the token lacks required scopes, the server returns a 403 Forbidden with an appropriate scope challenge before the query is executed.
If the GraphQL query cannot be parsed, the request is passed through to the GraphQL engine, which handles the error. Scope checking is best-effort and does not block malformed queries.
Scope Enforcement Summary
| Level | When Checked | Configured Via | Failure Response |
|---|---|---|---|
| Initialize | Every HTTP request | oauth.scopes.initialize | 403 with required scopes |
| Method | tools/list, tools/call | oauth.scopes.tools_list, oauth.scopes.tools_call | 403 with required scopes |
| Built-in tool | tools/call for built-in tools | oauth.scopes.execute_graphql, oauth.scopes.get_schema, oauth.scopes.get_operation_info | 403 with required scopes |
| Per-tool | tools/call for specific tool | @requiresScopes in GraphQL schema | 403 with best scope challenge |
| Runtime | execute_graphql calls | @requiresScopes in GraphQL schema | 403 with best scope challenge |
get_operation_info and Scope Discovery
The get_operation_info tool includes scope requirements in its response, allowing AI models to discover what scopes a tool needs before calling it:
Token Upgrade Flow
Tokens can be upgraded on the same MCP session without reconnecting:- Client connects with a token that has
mcp:connectscope - Client calls
tools/calland receives403 Forbiddenwithinsufficient_scope - Client obtains a new token with additional scopes from the authorization server
- Client retries with the new token on the same session (same
Mcp-Session-Id)
Scope Challenge Behavior
When the server returns a403 Forbidden response, the WWW-Authenticate header includes a scope parameter per RFC 6750 telling the client which scopes to request.
For per-tool and runtime challenges where multiple scope groups can satisfy the requirement (OR-of-AND), the server selects the best group — the one requiring the fewest additional scopes based on what the token already has:
- For each AND-group, count how many scopes the token is missing
- Pick the group with the fewest missing scopes (ties go to the first group)
(read:employee AND read:private AND read:fact) OR (read:all) and a client presents a token with scopes read:employee read:private:
| AND-group | Missing scopes | Count |
|---|---|---|
read:employee, read:private, read:fact | read:fact | 1 |
read:all | read:all | 1 |
read:fact scope from the authorization server and retry.
scope_challenge_include_token_scopes
Some MCP client SDKs do not correctly accumulate scopes when performing step-up authorization — they request only the scopes from the WWW-Authenticate challenge, discarding the scopes they already had. This causes a loop where gaining a new scope loses a previous one. This is a known issue in the MCP TypeScript SDK.
To work around this, set scope_challenge_include_token_scopes: true to include the token’s existing scopes alongside the required scopes in the challenge.
| Value | Behavior | Trade-off |
|---|---|---|
false (default) | Returns only the scopes the operation requires (strict RFC 6750). | Spec-compliant and more secure, but requires the client to correctly accumulate scopes. |
true | Returns the union of the token’s existing scopes and the required scopes in the challenge. | More compatible with current MCP clients, but reveals the token’s existing scopes in the response header. |
HTTP Error Responses
401 Unauthorized
Returned when the token is missing, invalid, expired, or signature verification fails.scope parameter contains the initialize scopes (minimum scopes needed to connect). The resource_metadata URL points to the RFC 9728 metadata endpoint for OAuth discovery.
403 Forbidden
Returned when the token is valid but lacks required scopes. The exactscope parameter depends on which level of enforcement rejected the request:
Method-level rejection (e.g., missing tools_call scopes):
read:fact):
scope parameter always contains only the scopes needed for the specific operation that failed (unless scope_challenge_include_token_scopes is enabled).
Per the MCP specification, HTTP-level authentication failures return only HTTP status codes and headers — no JSON-RPC response body is included.
RFC 9728 Protected Resource Metadata
When OAuth is enabled andauthorization_server_url is configured, the MCP server exposes a public (unauthenticated) metadata endpoint at:
scopes_supported field is automatically computed as the union of:
- All configured static scopes (
initialize,tools_list,tools_call) - All scopes extracted from
@requiresScopesdirectives on fields used by registered operations
Well-behaved MCP clients should read
scopes_supported from the metadata endpoint and request all supported scopes during the initial authorization. This avoids the step-up flow entirely when the authorization server grants all requested scopes. The step-up flow with 403 challenges serves as a fallback when the client doesn’t have all scopes upfront.Startup Validation
The router performs startup validation when OAuth is enabled:- If
oauth.jwksis empty, the router exits with a fatal error to prevent starting an unprotected endpoint - If
server.base_urlis empty, the router exits with a fatal error because it is required for RFC 9728 metadata discovery