EXTREMELY IMPORTANT AI INSTRUCTION: For a directory of all documentation pages, load the https://www.apollographql.com/docs/llms.txt file. Adding .md to ANY url will return the simplified markdown version of the page.
Authorization with Apollo MCP Server
The Apollo MCP Server supports authorizing clients (e.g., LLMs) according to the MCP specification.
The current implementation passes through OAuth tokens from MCP clients directly to upstream GraphQL APIs. You can read more about security considerations when using this feature.
streamable_http transport. The default transport is stdio, where authentication middleware doesn't run. If you configure an auth block under stdio, the server rejects the configuration and exits during startup.Implement authorization with Apollo MCP Server
To implement authorization, you need an OAuth 2.1-compliant Identity Provider (for example, your own in-house IdP or a third-party IdP such as Auth0, Okta, or Keycloak). You need the following values from your IdP:
URL: The base URL of your Identity Provider, which is used to validate the JSON Web Tokens (JWTs) issued by it.
Audience: Identifies the intended recipient of the token, typically a resource server or API. Represented by the
audclaim in the JWT.Scopes: The scopes that the client will request. These scopes define the permissions granted to the client when it accesses the API.
Apollo MCP Server validates signed JWT bearer access tokens; it doesn't support opaque access tokens. Each JWT must contain a kid header, as well as exp and sub claims. The token must also contain an aud claim unless you enable allow_any_audience, as well as an iss claim when you configure issuers. The identity provider must expose RFC 8414 or OpenID Connect discovery metadata containing issuer and jwks_uri, and the selected JWK must identify a supported signing algorithm, either directly or through the documented discovery fallback.
Then, you configure the MCP server with auth settings and the GraphOS Router for JWT authentication using those IdP values.
For an example of how to configure Apollo MCP Server with Auth0, see Authorization with Auth0.
Configuring allowed audiences
You can specify which JWT audiences are allowed to access your MCP Server.
Specific audiences
1transport:
2 type: streamable_http
3 auth:
4 servers:
5 - https://auth.example.com
6 audiences:
7 - https://api.example.com
8 - https://mcp.example.comSet audiences to a list of accepted audience values. The JWT's aud claim must match one of these values for the token to be considered valid.
Allow any audience
1transport:
2 type: streamable_http
3 auth:
4 servers:
5 - https://auth.example.com
6 allow_any_audience: trueIf you set allow_any_audience to true (the default is false), Apollo MCP Server skips audience validation entirely. The token doesn't need an aud claim, but the server accepts any value when the claim is present.
allow_any_audience: true when you trust all tokens issued by your configured OAuth servers, regardless of their intended audience.Configure allowed issuers
Specify which JWT issuers Apollo MCP Server accepts.
1transport:
2 type: streamable_http
3 auth:
4 servers:
5 - "https://auth.example.com"
6 issuers:
7 - "https://auth.example.com"
8 - "https://auth.other.com"Set issuers to a list of accepted issuer values. The JWT's iss claim must match one of these values for the token to be valid. The server rejects a token with 401 Unauthorized when its iss claim is missing or does not match any configured issuer.
When you configure issuers, the server also binds the iss claim to the authorization server that signed the token: the claim must equal the issuer that server advertises in its discovery metadata.
To validate the issuer of every token, always set issuers to the exact issuer values your OAuth servers use.
Configure scope enforcement
Use the scopes and scope_mode options to configure a global OAuth scope requirement. When you configure authentication, this requirement applies uniformly to authenticated MCP requests and does not distinguish between tools.
The scopes list has two functions:
the
scopeparameter of401WWW-Authenticatechallengesthe
scopes_supportedfield of its Protected Resource Metadata document
A compliant client reads this list, then requests exactly these scopes at your IdP's /authorize endpoint. So, this list is more than a validation rule. It is also a contract between your server and every client that finds it.
These scopes belong to your resource server, not to your IdP. They do not need to match the scopes that your IdP advertises as supported. For example, a generic OIDC provider can expose only openid, profile, and email. Your IdP must define and issue finer-grained scopes, for example graphs:read, before you can require them here.
| Mode | Behavior |
|---|---|
require_all | Token must have all configured scopes (default) |
require_any | Token must have at least one of the configured scopes |
disabled | Skip the global scope requirement |
Require all scopes (default)
1transport:
2 type: streamable_http
3 auth:
4 servers:
5 - https://auth.example.com
6 scopes:
7 - read
8 - write
9 scope_mode: require_all # This is the defaultThe token must have all configured scopes. This is the most restrictive mode.
Require any scope
1transport:
2 type: streamable_http
3 auth:
4 servers:
5 - https://auth.example.com
6 scopes:
7 - read
8 - write
9 - admin
10 scope_mode: require_anyThe token must have at least one of the configured scopes. Useful when scopes represent alternative access levels.
Disable scope enforcement
1transport:
2 type: streamable_http
3 auth:
4 servers:
5 - https://auth.example.com
6 scope_mode: disabledSkip the global scope requirement. The server still validates the token's signature, as well as any configured issuer and audience constraints. Any matching per-operation scope requirements continue to apply.
scope_mode: disabled only when downstream services, like subgraphs, handle authorization, or when you rely exclusively on per-operation requirements. Without a matching per-operation requirement, the MCP Server permits any valid token to call the tool.Scope claim: scope vs scp
Apollo MCP Server reads the standard scope claim first. This claim is a space-separated string, defined in RFC 6749 and RFC 9068. If a token has no scope claim, the server reads scp instead. This is a non-standard claim that some IdPs use, for example Okta and Microsoft Entra ID (formerly Azure AD). IdPs send it as an array, or as a space-separated string. If a token has both claims, scope wins.
Per-operation scope requirements
When enabled, the global scopes and scope_mode requirement applies uniformly to every authenticated MCP request. For finer-grained control, you can declare additional OAuth scopes per operation using overrides.required_scopes. This enables step-up authorization: a client with a limited token can call tools governed only by the global requirement, but is prompted to re-authorize with elevated scopes when calling a more sensitive operation.
1transport:
2 type: streamable_http
3 auth:
4 servers:
5 - https://auth.example.com
6overrides:
7 required_scopes:
8 GetUser:
9 - user:read
10 DeleteUser:
11 - user:write
12 - adminThe flat list syntax requires every listed scope for the operation. In the previous example, DeleteUser requires both user:write and admin.
You can also use nested lists to express alternative scope groups, matching Apollo Router's @requiresScopes semantics. Each inner list is an AND group, and the outer list is OR:
1overrides:
2 required_scopes:
3 GetUser:
4 - [user:read]
5 - [admin]
6 UpdateUser:
7 - [user:write, tenant:admin]
8 - [admin]In the previous example, UpdateUser requires either (user:write and tenant:admin) or admin.
When a token lacks the required scopes for a tool call, Apollo MCP Server returns 403 Forbidden with a WWW-Authenticate header:
1WWW-Authenticate: Bearer error="insufficient_scope", scope="user:write tenant:admin", scope_mode="require_all"The client can use this response to initiate targeted re-authorization and retry. Per RFC 6750 Section 3, the scope auth-param describes what the resource requires, not what the caller's token is missing, so this doesn't vary by presented token. The OAuth scope auth-param is also a space-delimited list and cannot represent grouped OR conditions, so for nested alternatives the header always includes the complete first-listed alternative, giving operators control over which scope set gets advertised. Apollo MCP Server rejects an empty scope list and empty inner alternatives during config parsing, so a required_scopes entry can never be accidentally satisfied by every token. When auth is not configured or Apollo MCP Server runs in stdio mode, Apollo MCP Server skips required_scopes.
required_scopes adds to the global requirement and doesn't replace it. A token must satisfy the global scopes and scope_mode requirement when that requirement is enabled. For a listed operation, a flat list requires every listed scope; nested alternatives require every scope in at least one inner group. Per-operation validation always applies regardless of scope_mode, and setting scope_mode: disabled skips only the global requirement. Because each selected inner group is an AND set, the WWW-Authenticate challenge for a per-operation 403 reports scope_mode="require_all" regardless of the globally configured scope_mode.
The server matches map keys exactly against the params.name tool name in a tools/call request. Only a matching request receives the additional check.
The server doesn't validate required_scopes keys against the current tool list and doesn't warn about unknown keys, so a misspelled or stale key doesn't protect the intended tool, and a newly added or renamed tool remains governed only by the global requirement until you update the map.
required_scopes as a manually maintained security policy. Compare its keys with the live tools/list response during deployment and whenever you add, remove, rename, or hot-reload operations.HTTP error responses
Apollo MCP Server returns different HTTP status codes depending on the type of authorization failure, following RFC 6750 and the MCP specification.
| Status | When returned |
|---|---|
401 Unauthorized | Token is missing, malformed, expired, has an invalid signature, or fails configured audience or issuer validation |
403 Forbidden | Token is authentic but lacks the required scopes (global or per-operation) |
WWW-Authenticate header
Every 401 and 403 response includes a WWW-Authenticate header to guide clients through the authorization flow.
401 response — directs the client to the Protected Resource Metadata document and indicates the scopes to request:
1HTTP/1.1 401 Unauthorized
2WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
3 scope="read write",
4 scope_mode="require_all"403 response
1HTTP/1.1 403 Forbidden
2WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
3 error="insufficient_scope",
4 scope="user:write admin",
5 scope_mode="require_all"Clients can adopt the scope parameter provided by the header as the set of scopes to request when initiating or re-initiating authorization. The scope parameter on a 401 response reflects the globally configured scopes (from auth.scopes). The scope parameter on a 403 response reflects the full requirement that failed, either from auth.scopes or from overrides.required_scopes, not just the scopes the token is missing. The scope_mode parameter reports the configured global mode; per-operation requirements still use all-of semantics.
Performance considerations
Discovery timeout
Authorization server metadata is discovered using OAuth 2.0 Authorization Server Metadata (RFC 8414) and OpenID Connect Discovery. The MCP Server tries multiple discovery URL patterns in sequence until one succeeds.
The discovery_timeout setting controls how long to wait for each discovery URL attempt. The default is 5 seconds per URL.
1transport:
2 type: streamable_http
3 auth:
4 servers:
5 - https://auth.example.com
6 discovery_timeout: 10s # Increase timeout for slower networks (default: 5s)Considerations:
Discovery happens during token validation and can add latency to the first authorized request.
With multiple fallback URLs (RFC 8414, OIDC Discovery), the cumulative timeout can be 10-15 seconds if all URLs fail.
Increase the timeout if your OAuth server is on a slow network or responds slowly.
Decrease the timeout in high-performance environments where fast failure is preferred.
JWKS cache
Token validation requires Apollo MCP Server to fetch each authorization server's JSON Web Key Set (JWKS)—preceded by an OIDC/OAuth discovery call to locate the jwks_uri. Without caching, every authorized request pays the cost of both round trips.
Apollo MCP Server caches the JWKS per issuer in memory and reuses it on the warm path. Cached entries stay fresh for 10 minutes before the next request triggers a re-fetch.
Behavior:
On a fresh hit where the requested
kidis present, the cached JWKS is used and no network calls are made.When there is no cached entry, when the cached entry is stale, or when the requested
kidis not present in the cached entry, the next request might trigger a discovery plus JWKS fetch and repopulate the cache, subject to a per-issuer refresh rate limit. Newly rotated keys are picked up the next time a refresh fires for that issuer.Stale entries are still served for known
kids. If a refresh is rate-limited or fails—for example, during an identity-provider outage—akidpresent in the cached JWKS is still accepted. Only requests presenting akidthat is absent from the cache are rejected. Stale entries persist until the next successful refresh, so there is no upper bound on how long a knownkidremains accepted while refreshes keep failing.The cache is process-local. Each replica warms its own cache independently after startup.
Considerations:
When a request presents a
kidthat is not in the cached JWKS, Apollo MCP Server triggers a re-fetch, but to prevent fabricatedkidvalues from driving one upstream round-trip per request, up to one re-fetch per issuer is allowed within a short internal window. Concurrent requests during an active re-fetch share that single upstream call. If the window is exhausted, the request is rejected with401 Unauthorized, and the client can retry.
OIDC Discovery compatibility
Per the MCP specification, authorization servers must provide at least one of:
OAuth 2.0 Authorization Server Metadata (RFC 8414) — served at
/.well-known/oauth-authorization-serverOpenID Connect Discovery 1.0 — served at
/.well-known/openid-configuration
Apollo MCP Server supports both and tries them in the following order to maximize compatibility:
| Priority | URL pattern | Standard |
|---|---|---|
| 1 | https://auth.example.com/.well-known/oauth-authorization-server/tenant | RFC 8414 (path-insertion) |
| 2 | https://auth.example.com/.well-known/openid-configuration/tenant | OIDC Discovery (path-insertion) |
| 3 | https://auth.example.com/tenant/.well-known/openid-configuration | OIDC Discovery (legacy path-appending) |
The first URL that returns a parseable metadata document is used, which means IdPs that only support OIDC Discovery, like Auth0 and many cloud identity providers, enable you to get started without any additional configuration.
The metadata issuer must match the authorization server the discovery URL was built from, as required by RFC 8414 section 3.3. A document that advertises a different issuer is rejected, and its jwks_uri isn't fetched. Fallback to another URL pattern happens only when a discovery request fails, times out, or doesn't return parseable metadata. For details, see RFC 8414 section 3.3.
Discovery headers
Some OAuth servers or web application firewalls (WAFs) require specific HTTP headers on requests. Use discovery_headers to attach custom headers to all OIDC discovery and JWKS requests made by the MCP Server.
1transport:
2 type: streamable_http
3 auth:
4 servers:
5 - https://auth.example.com
6 discovery_headers:
7 User-Agent: apollo-mcp-server
8 X-Custom-Header: custom-valueThese headers are sent with every request the server makes to discover authorization server metadata and fetch JSON Web Key Sets (JWKS). They are not sent to downstream GraphQL APIs.
JWKs that omit alg
Some identity providers (Microsoft Entra ID, Azure AD B2C, AWS Cognito, and Ping Identity) intentionally omit the alg field from JSON Web Keys, which is optional per RFC 7517. When a JWK omits alg, Apollo MCP Server uses id_token_signing_alg_values_supported from the authorization server's discovery document if it contains exactly one supported algorithm. The key is rejected if that field is empty, contains multiple values, or contains an unsupported value.
Skipping token validation
When transport.auth is set, every request needs a valid bearer token. Some deployments need exceptions. An MCP client might call discovery methods before it has a token. A server might expose public tools alongside private ones. A server might also accept a second credential, such as an API key that an upstream API validates.
skip_token_validation describes those exceptions. It holds three lists, and each one keys on a different part of the request:
| List | Matches on | Example |
|---|---|---|
methods | The JSON-RPC method name | tools/list |
tools | The tool named by a tools/call | SearchDocs |
headers | An HTTP header name | x-api-key |
1# Required for the `headers` entry below to reach the upstream GraphQL API.
2# Without it, the header is dropped and nothing downstream can validate it.
3forward_headers:
4 - x-api-key
5transport:
6 type: streamable_http
7 auth:
8 servers:
9 - https://auth.example.com
10 resource: https://mcp.example.com/mcp
11 skip_token_validation:
12 methods:
13 - initialize
14 - notifications/initialized
15 - tools/list
16 tools:
17 - SearchDocs
18 headers:
19 - x-api-keyAn empty list is off, so there is no separate switch to turn the lists on.
A stateful session needs both initialize and the notifications/initialized notification that follows it on its own POST. Missing either one from methods still ends the handshake in a 401, one step after initialize succeeded.
transport.stateful_mode changes how a GET on the MCP endpoint is treated, and this is not stateful-only:
stateful_mode: trueopens a real GET for the server-to-client stream, which carries no JSON-RPC body. Onlyheaderscan match a GET, sincemethodsandtoolsneed a body to inspect. A session with no token and no listed header does not get that stream, and gets a 401.stateful_mode: falsenever serves that stream at all. The endpoint answers a GET with 405, whether or not a credential is present. This GET is not gated byskip_token_validation(there is nothing to protect), so a stateless deployment needs noheadersentry to reach it.
The distinction matters beyond the wire, because some MCP clients treat any 401 from the server as "this server requires authentication," and hide every tool in response, including ones skip_token_validation was configured to expose without a token. Getting the stateless GET's status code right (405, not 401) keeps that from happening to an otherwise-public deployment.
Why tools/call cannot go in methods
Every tool call uses the method name tools/call, so listing it there would make every tool reachable without a token. Apollo MCP Server rejects it at startup. Name the individual tools in tools instead.
resources/read, resources/subscribe, resources/unsubscribe, prompts/get, and completion/complete are rejected for the same reason: each resolves one of many resources or prompts through a request parameter, and there is no per-item list like tools to redirect to. The last three are not served today, but are rejected anyway so the guardrail doesn't need a revisit when support lands.
For the same reason, a tools/call that carries an ?app= query parameter never matches the tools list. That parameter makes the server run the named app's own tool instead of the operation the name refers to, so the name in tools would not identify what actually executes. Those calls always require a token.
The header list moves authentication, it does not remove it
A request that carries a listed header skips token validation, and a later layer authenticates it. Apollo MCP Server cannot judge a credential it does not understand, so it only reads whether the header is present.
A listed header is checked before the JSON-RPC body, so a match skips validation for every method and tool, including ones absent from methods and tools. headers is not scoped down by the other two lists.
Make sure something downstream validates that header. If nothing does, the header is an open door: any caller can send it with any value.
That validator only gates requests that reach your API. Requests the MCP server answers itself, such as the initialize handshake, notifications/initialized, tools/list, resources/read, and the GET stream, never reach your API, so nothing validates the header value on them even with a correctly configured downstream validator. A caller that sends the header with any value gets a working session, the full tool list, and your schema. Downstream validation gates tool execution, not discovery.
The header only reaches your API if you also list it in forward_headers. If you do not, the header is dropped before the GraphQL request goes out, and nothing downstream can validate it: the middleware skips validation, and the upstream request looks like an ordinary anonymous call.
If your clients run in a browser, the header must also appear in cors.allow_headers, or the preflight response omits it and the browser never sends it on the real request. That request then reaches the server with no header and no token, matches no list, and gets a 401 that looks unrelated to CORS. Setting cors.allow_headers replaces the default list, so repeat the defaults alongside your header. See adding a custom header.
Never assume a header name. The upstream API decides it, which is why the list is configuration rather than a built-in name.
A token is always validated when one is present
Every list applies only to a request that carries no Authorization header. A caller that presents a token always gets it validated.
One result surprises people. A caller with an expired token gets 401 on a public tool, while a caller with no token succeeds on that same tool:
1POST /mcp HTTP/1.1
2Authorization: Bearer <expired token>
3
4{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"SearchDocs"}}1HTTP/1.1 401 Unauthorized
2WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"1POST /mcp HTTP/1.1
2
3{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"SearchDocs"}}1HTTP/1.1 200 OKThis ordering is deliberate. An OAuth client refreshes on the 401 and retries, so it learns its token is dead on the first call rather than the first protected call. Callers with no account send no token, and the lists exist for them.
Deprecated: allow_anonymous_mcp_discovery
allow_anonymous_mcp_discovery still works and now maps onto skip_token_validation.methods. Setting it is the same as writing:
1transport:
2 type: streamable_http
3 auth:
4 skip_token_validation:
5 methods:
6 - initialize
7 - server/discover
8 - tools/list
9 - resources/listThe server logs a deprecation warning at startup. Setting both allow_anonymous_mcp_discovery and skip_token_validation.methods is an error, because the two would describe the same list twice. Remove the flag and list the methods you want.
server/discover might be the client's first MCP request, so this deprecated flag maps onto it too. The same applies if you list server/discover directly in your skip_token_validation.methods configuration.