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.

note
Authentication is available only with the 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 aud claim 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

YAML
mcp.yaml
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.com

Set 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

YAML
mcp.yaml
1transport:
2  type: streamable_http
3  auth:
4    servers:
5      - https://auth.example.com
6    allow_any_audience: true

If 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.

caution
Skipping audience validation reduces security. Only use 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.

YAML
mcp.yaml
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:

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.

ModeBehavior
require_allToken must have all configured scopes (default)
require_anyToken must have at least one of the configured scopes
disabledSkip the global scope requirement

Require all scopes (default)

YAML
mcp.yaml
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 default

The token must have all configured scopes. This is the most restrictive mode.

Require any scope

YAML
mcp.yaml
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_any

The token must have at least one of the configured scopes. Useful when scopes represent alternative access levels.

Disable scope enforcement

YAML
mcp.yaml
1transport:
2  type: streamable_http
3  auth:
4    servers:
5      - https://auth.example.com
6    scope_mode: disabled

Skip 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.

caution
Adopt 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.

YAML
mcp.yaml
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      - admin

The 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:

YAML
mcp.yaml
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:

Text
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.

caution
Treat 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.

StatusWhen returned
401 UnauthorizedToken is missing, malformed, expired, has an invalid signature, or fails configured audience or issuer validation
403 ForbiddenToken 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:

http
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

http
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.

YAML
mcp.yaml
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 kid is 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 kid is 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—a kid present in the cached JWKS is still accepted. Only requests presenting a kid that 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 known kid remains 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 kid that is not in the cached JWKS, Apollo MCP Server triggers a re-fetch, but to prevent fabricated kid values 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 with 401 Unauthorized, and the client can retry.

OIDC Discovery compatibility

Per the MCP specification, authorization servers must provide at least one of:

Apollo MCP Server supports both and tries them in the following order to maximize compatibility:

PriorityURL patternStandard
1https://auth.example.com/.well-known/oauth-authorization-server/tenantRFC 8414 (path-insertion)
2https://auth.example.com/.well-known/openid-configuration/tenantOIDC Discovery (path-insertion)
3https://auth.example.com/tenant/.well-known/openid-configurationOIDC 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.

YAML
mcp.yaml
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-value

These 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:

ListMatches onExample
methodsThe JSON-RPC method nametools/list
toolsThe tool named by a tools/callSearchDocs
headersAn HTTP header namex-api-key
YAML
mcp.yaml
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-key

An 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: true opens a real GET for the server-to-client stream, which carries no JSON-RPC body. Only headers can match a GET, since methods and tools need a body to inspect. A session with no token and no listed header does not get that stream, and gets a 401.

  • stateful_mode: false never serves that stream at all. The endpoint answers a GET with 405, whether or not a credential is present. This GET is not gated by skip_token_validation (there is nothing to protect), so a stateless deployment needs no headers entry 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.

caution
These lists deviate from the MCP specification's requirement that authorization be present on every request. A client with no token can discover what the server exposes, and can call any tool you list. Only configure this when your deployment needs it, for example to support agent frameworks that list tools before they start an OAuth flow, or to keep public tools reachable for users who have no account with you.

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:

http
1POST /mcp HTTP/1.1
2Authorization: Bearer <expired token>
3
4{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"SearchDocs"}}
http
1HTTP/1.1 401 Unauthorized
2WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
http
1POST /mcp HTTP/1.1
2
3{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"SearchDocs"}}
http
1HTTP/1.1 200 OK

This 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:

YAML
mcp.yaml
1transport:
2  type: streamable_http
3  auth:
4    skip_token_validation:
5      methods:
6        - initialize
7        - server/discover
8        - tools/list
9        - resources/list

The 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.

note
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.