# Set up the embedded authorization server in Kubernetes

This guide shows you how to configure the [embedded authorization server](/toolhive/concepts/embedded-auth-server.md) on `MCPServer` and `MCPRemoteProxy` resources in Kubernetes. Use this approach when you want ToolHive to handle the full OAuth flow, including redirecting users to an upstream identity provider for authentication. It's ideal for MCP servers or remote MCP endpoints that accept `Authorization: Bearer` tokens but have no federation relationship with your identity provider, such as GitHub, Google Workspace, or Atlassian.

For the other ways to authenticate MCP servers in Kubernetes (connecting to an external IdP directly, or Kubernetes service-to-service authentication), see [Authentication and authorization](/toolhive/guides-k8s/auth-k8s.md).

## Prerequisites[​](#prerequisites "Direct link to Prerequisites")

You'll need:

* An upstream identity provider that supports the OAuth 2.0 authorization code flow (such as Okta, Microsoft Entra ID, Auth0, or any OIDC-compliant provider)
* Either a registered OAuth application/client or an upstream provider that supports RFC 7591 Dynamic Client Registration (DCR)
* For a registered client, its client ID and client secret

This setup uses the `MCPExternalAuthConfig` custom resource, following the same pattern as [token exchange configuration](/toolhive/guides-k8s/token-exchange-k8s.md).

The steps below create four resources and wire them together: an `MCPExternalAuthConfig` that runs the embedded auth server against your upstream provider, an `MCPOIDCConfig` that validates the JWTs it issues, and the `MCPServer` that references both.

<!-- -->

## Step 1: Create a Secret for the upstream provider client credentials[​](#step-1-create-a-secret-for-the-upstream-provider-client-credentials "Direct link to Step 1: Create a Secret for the upstream provider client credentials")

If you registered a client with the upstream provider, store its OAuth client secret:

upstream-idp-secret.yaml

```
apiVersion: v1

kind: Secret

metadata:

  name: upstream-idp-secret

  namespace: toolhive-system

type: Opaque

stringData:

  client-secret: '<YOUR_UPSTREAM_CLIENT_SECRET>'
```

```
kubectl apply -f upstream-idp-secret.yaml
```

Skip this step if you use DCR without an initial access token. If the upstream requires an initial access token for DCR, create a Secret for that token in [Use dynamic client registration with an upstream provider](#use-dynamic-client-registration-with-an-upstream-provider).

## Step 2: Create a Secret for JWT signing keys[​](#step-2-create-a-secret-for-jwt-signing-keys "Direct link to Step 2: Create a Secret for JWT signing keys")

The embedded authorization server signs JWTs with a private key you provide. Generate a PEM-encoded private key (RSA or EC), for example:

```
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out signing-key.pem
```

Then create a Secret containing the key:

auth-server-signing-key.yaml

```
apiVersion: v1

kind: Secret

metadata:

  name: auth-server-signing-key

  namespace: toolhive-system

type: Opaque

stringData:

  signing-key: |

    -----BEGIN PRIVATE KEY-----

    <YOUR_PEM_ENCODED_PRIVATE_KEY>

    -----END PRIVATE KEY-----
```

```
kubectl apply -f auth-server-signing-key.yaml
```

Key rotation

For key rotation, you can reference multiple signing key Secrets in the `signingKeySecretRefs` list. The first key is used for signing new tokens. Additional keys are used for verification only, so tokens signed before rotation remain valid.

## Step 3: Create a Secret for HMAC keys[​](#step-3-create-a-secret-for-hmac-keys "Direct link to Step 3: Create a Secret for HMAC keys")

The embedded authorization server uses a symmetric HMAC key to sign authorization codes and refresh tokens. The key must be at least 32 bytes and cryptographically random, for example:

```
openssl rand -base64 32
```

auth-server-hmac-secret.yaml

```
apiVersion: v1

kind: Secret

metadata:

  name: auth-server-hmac-secret

  namespace: toolhive-system

type: Opaque

stringData:

  hmac-key: '<YOUR_CRYPTOGRAPHICALLY_RANDOM_KEY>'
```

```
kubectl apply -f auth-server-hmac-secret.yaml
```

Ephemeral keys for development only

If you omit the `signingKeySecretRefs` and `hmacSecretRefs` fields, ToolHive generates ephemeral keys that are lost on pod restart. All previously issued tokens become invalid after a restart. Only omit these Secrets for development and testing.

## Step 4: Create the MCPExternalAuthConfig resource[​](#step-4-create-the-mcpexternalauthconfig-resource "Direct link to Step 4: Create the MCPExternalAuthConfig resource")

Create an `MCPExternalAuthConfig` resource with the `embeddedAuthServer` type. This example configures an OIDC upstream provider (the most common case):

embedded-auth-config.yaml

```
apiVersion: toolhive.stacklok.dev/v1beta1

kind: MCPExternalAuthConfig

metadata:

  name: embedded-auth-server

  namespace: toolhive-system

spec:

  type: embeddedAuthServer

  embeddedAuthServer:

    issuer: 'https://mcp.example.com'

    signingKeySecretRefs:

      - name: auth-server-signing-key

        key: signing-key

    hmacSecretRefs:

      - name: auth-server-hmac-secret

        key: hmac-key

    tokenLifespans:

      accessTokenLifespan: '1h'

      refreshTokenLifespan: '168h'

      authCodeLifespan: '10m'

    upstreamProviders:

      - name: google

        type: oidc

        oidcConfig:

          issuerUrl: 'https://accounts.google.com'

          clientId: '<YOUR_GOOGLE_CLIENT_ID>'

          clientSecretRef:

            name: upstream-idp-secret

            key: client-secret

          # redirectUri is set explicitly because resourceUrl (set on the

          # MCPServer in Step 5) includes a path; the default computed from

          # resourceUrl would land on a path nothing serves. See "Default

          # callback URL for upstream providers" below.

          redirectUri: 'https://mcp.example.com/oauth/callback'

          # Scopes must be set explicitly when using access_type=offline, to avoid

          # sending the default offline_access scope alongside Google's

          # access_type=offline mechanism (they serve the same purpose).

          scopes:

            - openid

            - email

            - profile

          additionalAuthorizationParams:

            access_type: 'offline'
```

```
kubectl apply -f embedded-auth-config.yaml
```

Keep `issuer` path-free

Set `issuer` to a bare host, with no path (for example, `https://mcp.example.com`, not `https://mcp.example.com/mcp`). The embedded authorization server's OAuth endpoints (`/oauth/register`, `/oauth/authorize`, `/oauth/token`, `/oauth/callback`) are always served at the host root, regardless of any path in `issuer`. Adding a path here makes discovery advertise endpoints that nothing actually serves, and every authentication attempt fails with a generic "authorization header required" error.

**Configuration reference:**

| Field                           | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `issuer`                        | HTTPS URL identifying this authorization server. Appears in the `iss` claim of issued JWTs. Must be a bare host with no path.                                                                                                                                                                                                                                                                                                                                                                               |
| `signingKeySecretRefs`          | References to Secrets containing JWT signing keys. First key is active; additional keys support rotation.                                                                                                                                                                                                                                                                                                                                                                                                   |
| `hmacSecretRefs`                | References to Secrets with symmetric keys for signing authorization codes and refresh tokens.                                                                                                                                                                                                                                                                                                                                                                                                               |
| `tokenLifespans`                | Configurable durations for access tokens (default: 1h), refresh tokens (default: 168h), and auth codes (default: 10m).                                                                                                                                                                                                                                                                                                                                                                                      |
| `upstreamProviders`             | Configuration for upstream identity providers. MCPServer and MCPRemoteProxy support one provider; VirtualMCPServer supports multiple providers for sequential authentication.                                                                                                                                                                                                                                                                                                                               |
| `cimd`                          | Optional Client ID Metadata Document (CIMD) configuration. When `cimd.enabled` is `true`, the auth server accepts HTTPS URLs as `client_id` values and resolves them via CIMD, letting clients (for example, VS Code) authenticate without prior Dynamic Client Registration. See [Enable CIMD for zero-registration clients](#enable-cimd-for-zero-registration-clients).                                                                                                                                  |
| `baselineClientScopes`          | Optional list of OAuth 2.0 scopes merged into every DCR-registered client's scope set. Use this when MCP clients register with a narrowed `scope` field but then request wider scopes at `/oauth/authorize`. See [Enable baseline scopes for DCR clients](#enable-baseline-scopes-for-dcr-clients).                                                                                                                                                                                                         |
| `disableUpstreamTokenInjection` | Optional. When `true`, the embedded auth server authenticates clients normally but the proxy strips `Authorization`, `Cookie`, and `Proxy-Authorization` from forwarded requests instead of swapping the JWT for an upstream token. Use this for public backends (such as documentation servers) that you still want to gate behind client auth. Cannot be combined with `tokenExchange`, `awsSts`, or OBO (Stacklok Enterprise) on the same workload, since each would re-add credentials after the strip. |

## Step 5: Create the MCPOIDCConfig and MCPServer resources[​](#step-5-create-the-mcpoidcconfig-and-mcpserver-resources "Direct link to Step 5: Create the MCPOIDCConfig and MCPServer resources")

The MCPServer needs two configuration references: `authServerRef` enables the embedded authorization server, and `oidcConfigRef` validates the JWTs that the embedded authorization server issues. Unlike connecting directly to an external identity provider, here the OIDC config points to the embedded authorization server itself. The MCPOIDCConfig issuer must match the `issuer` in your `MCPExternalAuthConfig`.

mcp-server-embedded-auth.yaml

```
apiVersion: toolhive.stacklok.dev/v1beta1

kind: MCPOIDCConfig

metadata:

  name: embedded-auth-oidc

  namespace: toolhive-system

spec:

  type: inline

  inline:

    # This must match the embedded authorization server issuer url

    issuer: 'https://mcp.example.com'

---

apiVersion: toolhive.stacklok.dev/v1beta1

kind: MCPServer

metadata:

  name: weather-server-embedded

  namespace: toolhive-system

spec:

  image: ghcr.io/stackloklabs/weather-mcp/server

  transport: streamable-http

  proxyPort: 8080

  # Reference the embedded authorization server configuration

  authServerRef:

    kind: MCPExternalAuthConfig

    name: embedded-auth-server

  # Validate JWTs issued by the embedded authorization server

  oidcConfigRef:

    name: embedded-auth-oidc

    audience: 'https://mcp.example.com/mcp'

    resourceUrl: 'https://mcp.example.com/mcp'

  resources:

    limits:

      cpu: '100m'

      memory: '128Mi'

    requests:

      cpu: '50m'

      memory: '64Mi'
```

```
kubectl apply -f mcp-server-embedded-auth.yaml
```

The `authServerRef` field is a `TypedLocalObjectReference` that requires both `kind` and `name`. This field is also available on `MCPRemoteProxy` resources.

note

The embedded authorization server exposes a JWKS endpoint that the proxy uses to validate the JWTs it issues. The proxy also exposes OAuth discovery endpoints (`/.well-known/oauth-authorization-server`) so MCP clients can discover the authorization endpoints automatically.

## Combine embedded auth with outgoing token exchange[​](#combine-embedded-auth-with-outgoing-token-exchange "Direct link to Combine embedded auth with outgoing token exchange")

A single MCP server can use the embedded authorization server for incoming client authentication and token exchange for outgoing calls to backend services. This works the same way regardless of which outgoing strategy you use: [RFC 8693 token exchange](/toolhive/guides-k8s/token-exchange-k8s.md) against your own IdP, or [AWS STS](/toolhive/integrations/aws-sts.md) for AWS-hosted backends. Set both `authServerRef` and `externalAuthConfigRef` on the same `MCPServer` or `MCPRemoteProxy` resource:

MCPServer or MCPRemoteProxy: authentication references

```
spec:

  # Embedded auth server for incoming client authentication

  authServerRef:

    kind: MCPExternalAuthConfig

    name: embedded-auth-server

  # Outgoing token exchange (type: tokenExchange or type: awsSts)

  externalAuthConfigRef:

    name: backend-auth-config

  oidcConfigRef:

    name: embedded-auth-oidc
```

For a complete walkthrough using AWS STS, see [Combine embedded auth with AWS STS](/toolhive/integrations/aws-sts.md#combine-embedded-auth-with-aws-sts). For plain token exchange, use the same pattern, but point `externalAuthConfigRef` at an `MCPExternalAuthConfig` with [`type: tokenExchange`](/toolhive/guides-k8s/token-exchange-k8s.md#step-2-create-the-mcpexternalauthconfig-resource) instead.

## Configure session storage[​](#configure-session-storage "Direct link to Configure session storage")

The embedded authorization server caches upstream tokens and client credentials obtained through DCR with an upstream provider in the same store as sessions. By default, that store is in memory, so these values are lost when pods restart and users must re-authenticate. When you use upstream DCR, a restart also registers a new client with the upstream and leaves the previous registration orphaned. For production deployments, configure a Redis backend so ToolHive can reuse the DCR credentials and sessions after a restart. Add one of the following `storage` blocks at `spec.embeddedAuthServer.storage` in the `MCPExternalAuthConfig`. The `redis` block supports three connection modes; you must set exactly one:

* **Sentinel** (`sentinelConfig`) - self-managed Redis with Sentinel-based high availability (HA)
* **Standalone** (`addr` only) - managed Redis services that expose a single endpoint, such as GCP Memorystore Basic/Standard or Azure Cache for Redis
* **Cluster** (`addr` with `clusterMode: true`) - managed Redis Cluster services, such as GCP Memorystore Cluster or AWS ElastiCache with cluster mode enabled

MCPExternalAuthConfig: Sentinel storage

```
storage:

  type: redis

  redis:

    sentinelConfig:

      masterName: mymaster

      sentinelService:

        name: redis-sentinel

        namespace: redis

    aclUserConfig:

      usernameSecretRef:

        name: redis-acl-secret

        key: username

      passwordSecretRef:

        name: redis-acl-secret

        key: password
```

MCPExternalAuthConfig: standalone storage

```
storage:

  type: redis

  redis:

    addr: redis.example.com:6379

    aclUserConfig:

      # usernameSecretRef is optional - omit for managed tiers without ACL

      # users (GCP Memorystore Basic/Standard, Azure Cache for Redis)

      passwordSecretRef:

        name: redis-acl-secret

        key: password
```

MCPExternalAuthConfig: cluster storage

```
storage:

  type: redis

  redis:

    addr: redis-cluster.example.com:6379

    clusterMode: true

    aclUserConfig:

      passwordSecretRef:

        name: redis-acl-secret

        key: password
```

Create the Secret containing your Redis credentials. The example below includes the username for ACL-enabled deployments; omit `--from-literal=username=...` when targeting a managed tier that does not support ACL users:

```
kubectl create secret generic redis-acl-secret \

  --namespace toolhive-system \

  --from-literal=username=toolhive-auth \

  --from-literal=password="YOUR_REDIS_PASSWORD"
```

For a complete walkthrough including deploying Redis Sentinel from scratch, see [Redis session storage](/toolhive/guides-k8s/redis-session-storage.md).

## Configure MCP client registration[​](#configure-mcp-client-registration "Direct link to Configure MCP client registration")

MCP clients must identify themselves to the embedded authorization server. The server accepts Dynamic Client Registration (DCR) requests from MCP clients with no configuration required, subject to configurable registration limits. Enable CIMD for clients that support the MCP specification's preferred registration mechanism. You can also set baseline scopes for clients that need to request scopes beyond those in their registration metadata.

### Enable CIMD for zero-registration clients[​](#enable-cimd-for-zero-registration-clients "Direct link to Enable CIMD for zero-registration clients")

Client-side DCR requires every client to register before its first authorization request. Some MCP clients, including recent VS Code builds, can instead present an HTTPS URL that hosts a Client ID Metadata Document (CIMD), letting the authorization server resolve client metadata on demand with no prior registration step. CIMD is the MCP specification's preferred client registration mechanism; client-side DCR is the backward-compatibility fallback. Add `cimd` under `spec.embeddedAuthServer` in the `MCPExternalAuthConfig`:

MCPExternalAuthConfig: CIMD configuration

```
spec:

  embeddedAuthServer:

    cimd:

      enabled: true

      cacheMaxSize: 256

      cacheFallbackTtl: '5m'
```

`cacheMaxSize` sets the LRU cache capacity (default `256`), and `cacheFallbackTtl` sets the TTL applied to every cached entry as a Go duration string (default `5m`). The CIMD fetcher doesn't yet honor `Cache-Control` headers; every cached document uses the fallback TTL. When disabled (the default), only DCR-registered `client_id` values are accepted.

If you also set `baselineClientScopes`, those scopes apply to CIMD-resolved clients too. Because CIMD clients can be resolved from arbitrary HTTPS URLs, keep the baseline narrow.

The embedded AS enforces the following rules on fetched CIMD documents:

* The URL must use `https` (loopback `http://localhost` is accepted in development environments only).
* The `client_id` field inside the document must exactly match the URL it was fetched from.
* `redirect_uris` must be present and pass strict validation.
* `token_endpoint_auth_method` must be `none` or omitted. When it names any other value (for example, `private_key_jwt`), the document must also publish a `token_endpoint_auth_methods_supported` list containing `none` (per OpenID Connect RP Metadata Choices 1.0), and the server negotiates down to `none`. Symmetric shared-secret methods (`client_secret_post`, `client_secret_basic`, `client_secret_jwt`) are always rejected, even alongside a supported-methods list.
* `grant_types` must include `authorization_code`. Unsupported entries in `grant_types` and `response_types` are filtered out rather than rejected; the document is only rejected when the filtered intersection lacks the `authorization_code` grant or the `code` response type.
* Declared scopes must be a subset of the AS's configured `scopes_supported` list (when set).

The fetcher also applies SSRF protection: DNS resolution runs before dialing, private IP ranges are blocked, redirects are not followed, and each fetch is subject to a five-second timeout and a 10 KB response cap.

For the two-layer trust model behind CIMD (why the upstream IdP never sees the client's CIMD URL), see [Client ID Metadata Document (CIMD)](/toolhive/concepts/embedded-auth-server.md#client-id-metadata-document-cimd).

### Limit Dynamic Client Registration[​](#limit-dynamic-client-registration "Direct link to Limit Dynamic Client Registration")

Because `/oauth/register` is unauthenticated, the embedded authorization server bounds how many clients it registers and how many it keeps:

* **Expiry:** a client that registers but never completes a token exchange expires after 24 hours. Its first successful token exchange extends the registration to 30 days, and a registration idle for more than 30 days expires. An expired client must register again.
* **Client cap:** `storage.maxDCRClients` caps the number of live DCR-registered clients (default `10000`). At the cap, `/oauth/register` returns HTTP `503` with a `Retry-After` header until a client expires; no existing client is evicted. Operator-configured clients and CIMD clients don't count toward the cap. With Redis storage, every replica shares the count; with in-memory storage, each replica keeps its own.
* **Rate limit:** `registrationRateLimit` is a token bucket that limits registrations per replica (default 1 per second with a burst of 5). Requests over the limit get HTTP `429` with a `Retry-After` header. Set `maxTokens` to the burst size and `refillPeriod` to the time it takes to refill the bucket from empty. For example, 10 registrations per minute with a burst of 3 is `maxTokens: 3` and `refillPeriod: 18s`.

The defaults suit most deployments. Raise the limits if many legitimate clients register in a short window, such as during a large client rollout:

MCPExternalAuthConfig: DCR limits

```
spec:

  embeddedAuthServer:

    registrationRateLimit:

      maxTokens: 10

      refillPeriod: 10s

    storage:

      type: redis

      maxDCRClients: 25000

      redis:

        # ...
```

When you size the cap, keep in mind that unused clients live for 24 hours. A caller registering at the full rate can keep up to the per-second rate, multiplied by the replica count and by 86,400 (the seconds in a day), unused clients alive. At the default rate, one replica fills the default cap in about 2.8 hours, and new registrations then fail with `503` until those clients expire.

Setting `maxDCRClients: 0` removes the cap, leaving the rate limit as the only bound on stored clients. Use it only temporarily, for example while an existing client population above the cap drains. A large finite value, such as two to three times your observed live client count, keeps some protection.

### Allow confidential DCR clients[​](#allow-confidential-dcr-clients "Direct link to Allow confidential DCR clients")

By default, DCR only issues public clients: registrations must use `token_endpoint_auth_method: "none"`, and the response carries no `client_secret`. Set `allowConfidentialClientRegistration: true` to also accept `client_secret_basic` and `client_secret_post` registrations and mint a real `client_secret` (returned exactly once in the registration response):

MCPExternalAuthConfig: confidential DCR

```
spec:

  embeddedAuthServer:

    allowConfidentialClientRegistration: true
```

`/oauth/register` is unauthenticated, so enabling this lets any caller who can reach the endpoint mint a client credential. Confidential registrations are restricted to `https` non-loopback redirect URIs, and they expire like any other DCR registration (see [Limit Dynamic Client Registration](#limit-dynamic-client-registration)).

To let an agent register a keypair for RFC 8693 token exchange instead of receiving a shared secret, set `allowPrivateKeyJWTRegistration: true`:

MCPExternalAuthConfig: private_key_jwt DCR

```
spec:

  embeddedAuthServer:

    allowPrivateKeyJWTRegistration: true
```

The agent registers its public JWKS and uses a signed client assertion at the token endpoint. See [Secretless delegate clients with `private_key_jwt`](/toolhive/guides-vmcp/delegate-agent-identity.md#secretless-delegate-clients-with-private_key_jwt) for the registration and exchange requests.

The following combinations are rejected at admission:

* `allowConfidentialClientRegistration: true` alongside `insecureAllowHTTP: true` - client secrets would be issued in cleartext.
* `allowConfidentialClientRegistration: true` with a plain-HTTP loopback issuer (for example, `http://localhost:8080`). Set `insecureAllowConfidentialOverLoopbackHTTP: true` to opt in to this combination for local development, where the traffic never leaves the machine.

Some MCP clients declare themselves public (`token_endpoint_auth_method: "none"`) at registration but then refuse to proceed because the response carries no `client_secret`. RFC 7591 permits the server to substitute client metadata; `forceConfidentialRedirectUris` takes such a client at its word and issues a real `client_secret` when its `redirect_uris` contains an exact match for a listed URI:

MCPExternalAuthConfig: force confidential for specific redirects

```
spec:

  embeddedAuthServer:

    allowConfidentialClientRegistration: true

    forceConfidentialRedirectUris:

      - https://client.example.com/oauth/callback
```

Every entry must be an `https` non-loopback URI. Matching is exact. Remove an entry once the client is fixed to handle public (`"none"`) registrations correctly.

### Pre-provision confidential clients for token exchange[​](#pre-provision-confidential-clients-for-token-exchange "Direct link to Pre-provision confidential clients for token exchange")

Confidential DCR is registration-driven and unauthenticated. For a client that you control and know in advance (typically a backend service that performs RFC 8693 token exchange against the embedded auth server), pre-provision it with `inboundGrants.tokenExchange.delegateClients` instead. Each entry references a Kubernetes Secret for the client secret and narrows the audiences and scopes the client may request:

MCPExternalAuthConfig: delegate clients

```
spec:

  embeddedAuthServer:

    inboundGrants:

      tokenExchange:

        delegateClients:

          - clientId: backend-exchange

            clientSecretRef:

              name: backend-exchange-client-secret

              key: client-secret

            audiences:

              - https://backend.example.com

            scopes:

              - backend-api:read
```

`clientId`, `clientSecretRef`, `audiences`, and `scopes` are all required. `audiences` and `scopes` narrow what the client can request at the token endpoint; leaving them empty is not permitted because a declared client must not receive every allowed audience or scope by default.

Delegate clients are independent of `allowConfidentialClientRegistration`: declaring one here does not enable self-service confidential DCR, and enabling DCR does not seed any delegate client. The two features govern different endpoints. An `https://` issuer protects the delegate client's secret. For local development, you can use a plain-HTTP loopback issuer with `insecureAllowConfidentialOverLoopbackHTTP: true`. ToolHive rejects a plain-HTTP non-loopback issuer.

To accept subject tokens minted by an external OIDC issuer, pair this with an `inboundGrants.tokenExchange.issuerPolicies` entry. The example below configures a delegate client named `coding-agent` to exchange user tokens from Microsoft Entra ID. It uses `disableUpstreamTokenInjection` because the backend is public: ToolHive validates the delegated token, then forwards the request without a credential.

MCPExternalAuthConfig: external-user delegation

```
spec:

  embeddedAuthServer:

    issuer: 'https://<MCP_SERVER_PROXY_HOST>:<PROXY_PORT>'

    disableUpstreamTokenInjection: true

    trustedIssuers:

      - name: entra-issuer

        issuerUrl: 'https://sts.windows.net/<TENANT_ID>/'

        jwksUrl: 'https://login.windows.net/<TENANT_ID>/discovery/v2.0/keys'

    inboundGrants:

      tokenExchange:

        delegateClients:

          - clientId: coding-agent

            clientSecretRef:

              name: coding-agent-secret

              key: client-secret

            audiences:

              - 'https://<RESOURCE_IDENTIFIER>'

            scopes:

              - openid

        issuerPolicies:

          - issuerRef: entra-issuer

            expectedAudience: 'https://<RESOURCE_IDENTIFIER>'

            actorClaim: appid

            allowedActors:

              - '<EXTERNAL_CLIENT_ID>'

            allowedDelegateClients:

              - coding-agent
```

The `issuer` must be the `MCPServer` proxy URL. Set the same URL on the `MCPOIDCConfig` that validates its tokens, and set `authServerRef` on the `MCPServer` to this `MCPExternalAuthConfig`, as shown in Step 5. The `expectedAudience` and delegate client's `audiences` must match the resource identifier in the external user's access token.

Because this configuration only exchanges tokens, it does not need an `upstreamProviders` entry. Add an upstream provider only when clients must also sign in through the embedded authorization server.

### Trust an external issuer using a private CA[​](#trust-an-external-issuer-using-a-private-ca "Direct link to Trust an external issuer using a private CA")

If the external issuer's discovery or JWKS endpoint uses a private certificate authority (CA), place its PEM certificate in a ConfigMap:

Create a ConfigMap with the external issuer CA

```
kubectl create configmap external-issuer-ca \

  --from-file=ca.crt=<PATH_TO_CA_CERTIFICATE>
```

Reference that ConfigMap from the corresponding trusted issuer:

MCPExternalAuthConfig: trusted issuer private CA

```
spec:

  embeddedAuthServer:

    trustedIssuers:

      - issuerUrl: 'https://<ISSUER_HOST>'

        caBundleRef:

          configMapRef:

            name: external-issuer-ca

            key: ca.crt
```

ToolHive adds this certificate to the system trust store only for this issuer's discovery and JWKS requests. Restrict write access to the ConfigMap: changing its contents changes the trust anchor used to validate subject tokens.

For why delegation exists, the consent policy options, and a worked exchange, see [Delegate agent identity with token exchange](/toolhive/guides-vmcp/delegate-agent-identity.md).

### Enable baseline scopes for DCR clients[​](#enable-baseline-scopes-for-dcr-clients "Direct link to Enable baseline scopes for DCR clients")

Some MCP clients (for example, Claude Code) register via DCR with a narrowed `scope` value, then request a wider set of scopes at `/oauth/authorize`. By default, the embedded authorization server rejects those requests with `invalid_scope` because the registered client's scope set doesn't include the scopes being requested. Set `baselineClientScopes` under `spec.embeddedAuthServer` in the `MCPExternalAuthConfig`:

MCPExternalAuthConfig: baseline client scopes

```
spec:

  embeddedAuthServer:

    baselineClientScopes:

      - openid

      - offline_access
```

The server merges these scopes into every DCR-registered client's scope set, so clients can request them at `/oauth/authorize` regardless of what they originally registered with. If `scopesSupported` is set explicitly on the embedded auth server, all baseline values must appear in it; if `scopesSupported` is omitted, the server validates against its default scope set (`openid`, `profile`, `email`, `offline_access`).

Keep the baseline narrow (typically `openid` and `offline_access`). Every DCR-registered client gains the ability to request these scopes, including public clients like Claude Code, Cursor, and VS Code, so privileged scopes don't belong in the baseline. For the conceptual reason this exists, see [Baseline scopes for DCR clients](/toolhive/concepts/embedded-auth-server.md#baseline-scopes-for-dcr-clients).

## Configure upstream providers[​](#configure-upstream-providers "Direct link to Configure upstream providers")

The base setup in Step 4 uses a pre-registered OIDC client. Use the options in this section to adapt that upstream connection:

* Use `oauth2Config` instead of `oidcConfig` when the upstream provider does not support OIDC discovery.
* Within `oidcConfig` or `oauth2Config`, set either pre-provisioned client credentials or `dcrConfig`, not both.
* Add identity mapping, authorization parameters, and an explicit callback URL as the upstream provider requires.

### Using an OAuth 2.0 upstream provider[​](#using-an-oauth-20-upstream-provider "Direct link to Using an OAuth 2.0 upstream provider")

If your upstream identity provider does not support OIDC discovery, you can configure it as an OAuth 2.0 provider with explicit endpoints. This is useful for providers like GitHub that use OAuth 2.0 but don't implement the full OIDC specification. If the provider supports RFC 7591 instead of pre-provisioned client credentials, see [Use dynamic client registration with an upstream provider](#use-dynamic-client-registration-with-an-upstream-provider).

embedded-auth-oauth2-config.yaml

```
apiVersion: toolhive.stacklok.dev/v1beta1

kind: MCPExternalAuthConfig

metadata:

  name: embedded-auth-oauth2

  namespace: toolhive-system

spec:

  type: embeddedAuthServer

  embeddedAuthServer:

    issuer: 'https://mcp.example.com'

    signingKeySecretRefs:

      - name: auth-server-signing-key

        key: signing-key

    hmacSecretRefs:

      - name: auth-server-hmac-secret

        key: hmac-key

    upstreamProviders:

      - name: github

        type: oauth2

        oauth2Config:

          authorizationEndpoint: 'https://github.com/login/oauth/authorize'

          tokenEndpoint: 'https://github.com/login/oauth/access_token'

          userInfo:

            endpointUrl: 'https://api.github.com/user'

            httpMethod: GET

            additionalHeaders:

              Accept: 'application/vnd.github+json'

            fieldMapping:

              subjectFields:

                - id

                - login

              nameFields:

                - name

                - login

              emailFields:

                - email

          clientId: '<YOUR_GITHUB_CLIENT_ID>'

          clientSecretRef:

            name: upstream-idp-secret

            key: client-secret

          # See the note on the Google example above: set redirectUri

          # explicitly whenever resourceUrl includes a path.

          redirectUri: 'https://mcp.example.com/oauth/callback'

          scopes:

            - user:email

            - read:user
```

note

OAuth 2.0 providers require explicit endpoint configuration, unlike OIDC providers which auto-discover these from the issuer URL. The `userInfo` section is optional: when present, the embedded auth server fetches user identity claims from the configured endpoint, and the `fieldMapping` section maps provider-specific response fields to standard user identity fields (for example, GitHub returns `login` instead of the standard `name` field).

When you omit `userInfo` and `identityFromToken`, the embedded auth server runs in synthesis mode for this upstream: it derives a non-personally-identifying subject (with a `tk-` prefix) from the access token and leaves `name` and `email` empty. Use this configuration for OAuth 2.0 servers that don't expose a userinfo endpoint and don't return identity in the token response, such as MCP authorization servers that comply with the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization). For OAuth 2.0 servers that return identity in the token response itself, see [Extract identity from the token response](#extract-identity-from-the-token-response).

#### Select the token endpoint auth method[​](#select-the-token-endpoint-auth-method "Direct link to Select the token endpoint auth method")

Pre-registered OAuth 2.0 upstream clients send credentials to the token endpoint in the request POST body (`client_secret_post`) by default, whether or not a client secret is configured. Public clients without a secret use the `none` method.

Set `tokenEndpointAuthMethod: client_secret_basic` on `oauth2Config` for providers that require credentials in the HTTP `Authorization` header instead of the request body:

MCPExternalAuthConfig: token endpoint auth method

```
oauth2Config:

  authorizationEndpoint: 'https://example.com/oauth/authorize'

  tokenEndpoint: 'https://example.com/oauth/token'

  clientId: '<CLIENT_ID>'

  clientSecretRef:

    name: upstream-idp-secret

    key: client-secret

  tokenEndpointAuthMethod: client_secret_basic
```

Allowed values are `none`, `client_secret_basic`, and `client_secret_post`. DCR clients continue to use the method negotiated at registration time.

### Trust a private CA for the upstream provider[​](#trust-a-private-ca-for-the-upstream-provider "Direct link to Trust a private CA for the upstream provider")

If the upstream identity provider serves its endpoints with a certificate signed by an internal CA (for example, an in-cluster Keycloak or a corporate ADFS behind private PKI), the embedded authorization server can't complete discovery, token, user-info, or DCR requests to it until it trusts that CA. Set `caBundleRef` on the upstream's `oidcConfig` or `oauth2Config` to point at a ConfigMap containing the PEM-encoded CA bundle. The operator projects it read-only into the proxy pod and the auth server adds it to the system trust roots for connections to this upstream only. The bundle augments the system roots; it doesn't restrict trust to this CA or disable public roots.

First, create a ConfigMap with the CA certificate. The ConfigMap must live in the same namespace as the `MCPExternalAuthConfig`:

```
kubectl create configmap upstream-private-ca \

  --from-file=ca.crt=/path/to/upstream-ca.crt \

  -n toolhive-system
```

Then reference it from the upstream provider's `oidcConfig`:

MCPExternalAuthConfig: OIDC upstream with a private CA

```
spec:

  type: embeddedAuthServer

  embeddedAuthServer:

    issuer: 'https://mcp.example.com'

    upstreamProviders:

      - name: corporate-idp

        type: oidc

        oidcConfig:

          issuerUrl: 'https://idp.internal.example.com'

          clientId: 'toolhive-client'

          caBundleRef:

            configMapRef:

              name: upstream-private-ca

              key: ca.crt
```

Use the same pattern under `oauth2Config` when the upstream is a pure OAuth 2.0 provider. Set `key` to the ConfigMap key holding the bundle, commonly `ca.crt`. The operator watches the ConfigMap and rolls the proxy pod when the bundle changes or the reference is removed, so rotating the CA doesn't require a manual restart. When the bundle content is invalid PEM, the operator surfaces a terminal condition on the resource that references this `MCPExternalAuthConfig` (for example, the `MCPServer` or `VirtualMCPServer`) rather than retrying reconciliation.

`caBundleRef` on the upstream provider is independent of `caBundleRef` on the `MCPOIDCConfig` that validates incoming JWTs (see [Use a custom CA certificate for the OIDC issuer](/toolhive/guides-k8s/auth-k8s.md#use-a-custom-ca-certificate-for-the-oidc-issuer)): the two references configure trust for different network hops.

### Use dynamic client registration with an upstream provider[​](#use-dynamic-client-registration-with-an-upstream-provider "Direct link to Use dynamic client registration with an upstream provider")

Some providers register clients dynamically instead of requiring you to create an application in a provider dashboard. Add `dcrConfig` to an `oidcConfig` or `oauth2Config` upstream to have the embedded authorization server register itself at runtime using RFC 7591.

In this flow, ToolHive registers with the upstream provider as an OAuth client. This is separate from MCP clients registering with ToolHive's embedded authorization server in [Configure MCP client registration](#configure-mcp-client-registration).

#### DCR with an OIDC upstream[​](#dcr-with-an-oidc-upstream "Direct link to DCR with an OIDC upstream")

For an OIDC upstream, an empty `dcrConfig: {}` is enough: the embedded auth server derives the discovery URL from `issuerUrl + /.well-known/openid-configuration` and reads the registration endpoint from the document.

MCPExternalAuthConfig: OIDC upstream with DCR

```
spec:

  type: embeddedAuthServer

  embeddedAuthServer:

    issuer: 'https://toolhive.example.com'

    upstreamProviders:

      - name: keycloak

        type: oidc

        oidcConfig:

          issuerUrl: 'https://keycloak.example.com/realms/main'

          dcrConfig: {}
```

Set `dcrConfig.discoveryUrl` explicitly when the upstream publishes discovery at a non-standard path, or `dcrConfig.registrationEndpoint` to bypass discovery entirely.

Don't set `clientId` or `clientSecretRef` when you set `dcrConfig`. ToolHive obtains the client ID and client secret from the DCR response.

#### DCR with an OAuth 2.0 upstream[​](#dcr-with-an-oauth-20-upstream "Direct link to DCR with an OAuth 2.0 upstream")

For an OAuth 2.0 upstream, use an RFC 8414 discovery document. ToolHive reads the `registration_endpoint` and other provider metadata from `discoveryUrl`:

embedded-auth-config-dcr.yaml

```
apiVersion: toolhive.stacklok.dev/v1beta1

kind: MCPExternalAuthConfig

metadata:

  name: embedded-auth-server-dcr

  namespace: toolhive-system

spec:

  type: embeddedAuthServer

  embeddedAuthServer:

    issuer: 'https://toolhive.example.com'

    signingKeySecretRefs:

      - name: auth-server-signing-key

        key: signing-key

    hmacSecretRefs:

      - name: auth-server-hmac-secret

        key: hmac-key

    upstreamProviders:

      - name: remote-mcp

        type: oauth2

        oauth2Config:

          authorizationEndpoint: 'https://mcp.example.com/authorize'

          tokenEndpoint: 'https://mcp.example.com/token'

          redirectUri: 'https://toolhive.example.com/oauth/callback'

          dcrConfig:

            discoveryUrl: 'https://mcp.example.com/.well-known/oauth-authorization-server'
```

Set `authorizationEndpoint` and `tokenEndpoint` even when the discovery document advertises them. The CRD requires both fields on `oauth2Config`.

For a complete deployment that uses `discoveryUrl` with Notion, see the [Notion MCP server guide](/toolhive/guides-mcp/notion-remote.md).

If the provider gives you a registration URL directly, use `registrationEndpoint` instead of `discoveryUrl` in the `MCPExternalAuthConfig` provider's `oauth2Config`:

MCPExternalAuthConfig: direct DCR endpoint

```
upstreamProviders:

  - name: remote-mcp

    type: oauth2

    oauth2Config:

      authorizationEndpoint: 'https://mcp.example.com/authorize'

      tokenEndpoint: 'https://mcp.example.com/token'

      scopes:

        - mcp:read

      dcrConfig:

        registrationEndpoint: 'https://mcp.example.com/register'
```

Set at most one of `discoveryUrl` or `registrationEndpoint`. A direct registration endpoint bypasses discovery, so specify the authorization and token endpoints and the scopes that ToolHive should request.

#### Authorize registration with an initial access token[​](#authorize-registration-with-an-initial-access-token "Direct link to Authorize registration with an initial access token")

If the upstream provider requires an initial access token, create a Secret in the same namespace:

dcr-initial-access-token.yaml

```
apiVersion: v1

kind: Secret

metadata:

  name: dcr-initial-access-token

  namespace: toolhive-system

type: Opaque

stringData:

  token: '<YOUR_DCR_INITIAL_ACCESS_TOKEN>'
```

Then add `initialAccessTokenRef` to the upstream provider's `oauth2Config.dcrConfig` in the `MCPExternalAuthConfig`. ToolHive sends the Secret value as a bearer token when it calls the registration endpoint:

MCPExternalAuthConfig: DCR initial access token

```
dcrConfig:

  registrationEndpoint: 'https://mcp.example.com/register'

  initialAccessTokenRef:

    name: dcr-initial-access-token

    key: token
```

#### Re-register a dropped upstream client[​](#re-register-a-dropped-upstream-client "Direct link to Re-register a dropped upstream client")

ToolHive caches the client it registers and reuses it across restarts. Each time the proxy starts, ToolHive checks the cached client at the upstream. If the upstream rejects it, ToolHive registers a new client and replaces the cached one automatically.

Choose how ToolHive runs that check with `registrationProbe` in `dcrConfig`:

| Value                     | How ToolHive checks the cached client                                                                                                                                                                                                                                                                                |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tokenEndpoint` (default) | Redeems a random authorization code at the token endpoint and treats an `invalid_client` answer as a dropped registration. This works only for upstreams that authenticate the client before they validate the code, and it skips `private_key_jwt` clients.                                                         |
| `authorizationEndpoint`   | Sends an authorization request for the cached client without following redirects. A direct `400`, `401`, or `403` response with a JSON error of `invalid_client`, `unauthorized_client`, or `invalid_request` counts as a dropped registration. An HTML error page or a bare gateway status keeps the cached client. |
| `none`                    | Skips the check. Use this for upstreams that shouldn't receive probe traffic. The only way to replace the registration is to change `rotationKey`.                                                                                                                                                                   |

Use `authorizationEndpoint` for upstreams whose token endpoint answers `invalid_grant` for any client ID, live or dropped. The probe sends no client credentials, so it also covers `private_key_jwt` clients. The upstream sees one abandoned authorization request each time the proxy starts.

MCPExternalAuthConfig: probe the authorization endpoint

```
dcrConfig:

  discoveryUrl: 'https://mcp.example.com/.well-known/oauth-authorization-server'

  registrationProbe: authorizationEndpoint
```

If the probe wrongly reports a live client as dropped, ToolHive registers a new client at every start, and startup fails when that registration also fails. If you see repeated registrations, switch back to `tokenEndpoint` or set `none`.

When the configured probe can't tell that a registration was dropped, the stale client ID is only rejected when a user signs in through the browser. To force a fresh registration, set or change `rotationKey` in `dcrConfig`:

MCPExternalAuthConfig: rotate the DCR client

```
dcrConfig:

  discoveryUrl: 'https://mcp.example.com/.well-known/oauth-authorization-server'

  rotationKey: '2026-10-19'
```

The value is a label of 1 to 64 letters, digits, dots, underscores, or hyphens. ToolHive uses it only to identify the cached registration and never sends it to the upstream. When the value changes, the proxy registers a new client at its next start. Upstream refresh tokens issued to the previous client stop working, so affected users must sign in again.

### Omit scopes on token refresh[​](#omit-scopes-on-token-refresh "Direct link to Omit scopes on token refresh")

By default, the embedded authorization server sends the configured `scopes` when it refreshes an upstream token, because some providers narrow the refreshed token when the `scope` parameter is missing. Providers such as Salesforce reject refresh requests that include `scope` (`invalid_request - scope parameter not supported`). For these providers, set `omitScopeOnRefresh: true` on the upstream's `oidcConfig` or `oauth2Config`:

MCPExternalAuthConfig: omit scope on refresh

```
oauth2Config:

  authorizationEndpoint: 'https://login.salesforce.com/services/oauth2/authorize'

  tokenEndpoint: 'https://login.salesforce.com/services/oauth2/token'

  scopes:

    - api

    - refresh_token

  omitScopeOnRefresh: true
```

ToolHive still sends the scopes on the authorization request, so the provider grants them at sign-in and the refreshed token keeps the originally granted scopes.

### Extract identity from the token response[​](#extract-identity-from-the-token-response "Direct link to Extract identity from the token response")

Some providers don't expose a userinfo endpoint but return user identity in the OAuth 2.0 token response itself. For these providers, set `identityFromToken` on the upstream provider's `oauth2Config` in the `MCPExternalAuthConfig` instead of `userInfo`. The embedded auth server then skips the userinfo HTTP call and extracts identity from the token response body using [gjson dot-notation paths](https://github.com/tidwall/gjson#path-syntax): `username` extracts a top-level field, `authed_user.id` extracts a nested field, and the pipe operator chains modifiers like `@upstreamjwt`.

For example, Slack's `oauth.v2.access` response includes the authenticated user ID at `authed_user.id`:

MCPExternalAuthConfig: Slack identity mapping

```
oauth2Config:

  identityFromToken:

    subjectPath: authed_user.id
```

Snowflake returns the authenticated login name as a top-level `username` field in every authorization-code grant response, and does not expose a userinfo endpoint:

MCPExternalAuthConfig: Snowflake identity mapping

```
oauth2Config:

  identityFromToken:

    subjectPath: username

    namePath: username
```

For providers whose token response embeds identity inside a JWT-shaped access token, the `@upstreamjwt` modifier decodes the JWT payload so subsequent path segments can drill into it:

MCPExternalAuthConfig: JWT-embedded identity mapping

```
oauth2Config:

  identityFromToken:

    subjectPath: 'access_token|@upstreamjwt|sub'
```

`subjectPath` is required; `namePath` and `emailPath` are optional. Omit `namePath` and `emailPath` rather than setting them to empty strings.

If you set both `identityFromToken` and `userInfo`, `identityFromToken` takes precedence and the userinfo HTTP call is skipped. If `identityFromToken` is set and extraction fails (path missing or unexpected type), authentication fails for that login attempt. There is no fallback to `userInfo`.

Trust model

Claims read from the token response are trusted via TLS only and are not cryptographically verified. The `@upstreamjwt` modifier decodes the JWT payload without verifying its signature. Prefer OIDC ID tokens when you need cryptographically verifiable claims.

### Upstream-specific authorization parameters[​](#upstream-specific-authorization-parameters "Direct link to Upstream-specific authorization parameters")

Some identity providers require custom query parameters on the authorization URL that aren't part of the standard OAuth 2.0 or OIDC specs. The most common case is Google, which issues refresh tokens only when the authorization request includes `access_type=offline` - Google's non-standard alternative to the `offline_access` scope.

To pass these parameters, set `additionalAuthorizationParams` on the `oidcConfig` or `oauth2Config` under `spec.embeddedAuthServer.upstreamProviders` in the `MCPExternalAuthConfig`:

MCPExternalAuthConfig: Google OIDC provider

```
upstreamProviders:

  - name: google

    type: oidc

    oidcConfig:

      issuerUrl: 'https://accounts.google.com'

      clientId: '<YOUR_GOOGLE_CLIENT_ID>'

      clientSecretRef:

        name: upstream-idp-secret

        key: client-secret

      scopes:

        - openid

        - email

        - profile

      additionalAuthorizationParams:

        access_type: 'offline'
```

**Scope interaction:** When an OIDC upstream does not specify `scopes` explicitly, ToolHive sends the default `["openid", "offline_access"]`. If you're using `access_type=offline` for a provider that does not understand `offline_access` (such as Google), set `scopes` explicitly so the two mechanisms don't conflict.

**First consent only:** Google only issues a refresh token on the user's first consent. If users previously authorized the app without `access_type=offline`, they won't receive a refresh token until their current access token expires and they sign in again. To force re-consent immediately (for example, after losing refresh-token state), add `prompt: 'consent'` alongside `access_type: 'offline'`. Google then shows the consent screen on every login and re-issues a refresh token each time.

### Upstream-specific token-request parameters[​](#upstream-specific-token-request-parameters "Direct link to Upstream-specific token-request parameters")

Some upstream authorization servers enforce [RFC 8707 resource indicators](https://datatracker.ietf.org/doc/html/rfc8707) on token requests, not only on authorization requests: the code exchange and refresh are rejected with `invalid_target` unless the `resource` parameter is also present in the token endpoint's POST form body. `additionalAuthorizationParams` only reaches the authorize URL, so against such a provider the embedded auth server passes authorization and then fails at the code exchange.

Set `additionalTokenParams` on the same `oidcConfig` or `oauth2Config` to add form-body parameters to every token request (both the authorization-code exchange and refresh):

MCPExternalAuthConfig: token-request resource indicator

```
upstreamProviders:

  - name: mcp-authz

    type: oauth2

    oauth2Config:

      authorizationEndpoint: 'https://as.example.com/oauth/authorize'

      tokenEndpoint: 'https://as.example.com/oauth/token'

      clientId: '<YOUR_CLIENT_ID>'

      clientSecretRef:

        name: upstream-idp-secret

        key: client-secret

      scopes:

        - openid

      additionalAuthorizationParams:

        resource: 'https://api.example.com'

      additionalTokenParams:

        resource: 'https://api.example.com'
```

The following framework-managed parameters are reserved and rejected at admission: `grant_type`, `code`, `redirect_uri`, `client_id`, `client_secret`, `code_verifier`, `refresh_token`, `scope`, `client_assertion`, and `client_assertion_type`. A `resource` entry is additionally validated as an absolute URI with no fragment component so a malformed value fails at admission rather than at first login. The map is capped at 16 entries.

### Default callback URL for upstream providers[​](#default-callback-url-for-upstream-providers "Direct link to Default callback URL for upstream providers")

When you omit `redirectUri` from an upstream provider's `oidcConfig` or `oauth2Config`, the operator defaults it to `{resourceUrl}/oauth/callback`. `resourceUrl` is the `oidcConfigRef.resourceUrl` set on the MCPServer or VirtualMCPServer that references this MCPExternalAuthConfig. It's typically the external URL that MCP clients use to reach the server.

You still need to register this callback URL with your upstream OAuth2 or OIDC provider before the flow can complete. Use the same URL on both sides: the value computed from `resourceUrl` here, and the authorized redirect URI in your provider's application settings.

For example, given this `oidcConfigRef` on an `MCPServer`:

MCPServer: oidcConfigRef

```
spec:

  oidcConfigRef:

    name: embedded-auth-oidc

    audience: 'https://mcp.example.com/mcp'

    resourceUrl: 'https://mcp.example.com/mcp'
```

Omitting `redirectUri` on the upstream provider resolves the callback to `https://mcp.example.com/mcp/oauth/callback`:

MCPExternalAuthConfig: Google OIDC provider

```
upstreamProviders:

  - name: google

    type: oidc

    oidcConfig:

      issuerUrl: 'https://accounts.google.com'

      clientId: '<YOUR_GOOGLE_CLIENT_ID>'

      clientSecretRef:

        name: upstream-idp-secret

        key: client-secret

      # redirectUri omitted - defaults to:

      # https://mcp.example.com/mcp/oauth/callback
```

Set `redirectUri` explicitly if you need a non-default callback path, for example to route the callback through a separate gateway hostname. If `resourceUrl` is also unset, no default is applied and the upstream provider must have `redirectUri` set explicitly.

Set `redirectUri` explicitly when `resourceUrl` has a path

The default above only works when `resourceUrl` is a bare host. In the examples on this page, `resourceUrl` includes a path (`/mcp`), so the computed default (`https://mcp.example.com/mcp/oauth/callback`) lands on a path nothing serves: the callback route is only ever served at the host root, matching the path-free `issuer` from [Step 4](#step-4-create-the-mcpexternalauthconfig-resource). Whenever `resourceUrl` has a path, set `redirectUri` explicitly to `<issuer>/oauth/callback` instead of relying on the default. This is the most common cause of "authorization header required" errors on every authentication attempt.

The upstream `redirectUri` hostname must match the browser-facing authorize URL (`issuer`, or `authorizationEndpointBaseUrl` when it's set). `/oauth/callback` completes each login only for the browser that started it at `/oauth/authorize` or the device verification page. The binding is a host-only cookie set on the authorize hostname, so a callback on a different host never receives the cookie and every browser login through that upstream is rejected. The scheme matters too: an `https` authorize URL sets a `Secure` cookie that browsers won't send to an `http` callback on a non-loopback host. Config validation logs a `WARN` at startup for either mismatch instead of refusing to start, so token refresh and non-interactive flows keep working.

## Test your setup[​](#test-your-setup "Direct link to Test your setup")

1. Deploy the `MCPExternalAuthConfig` and `MCPServer` resources

2. Check that the MCPServer is running:

   ```
   kubectl get mcpserver -n toolhive-system weather-server-embedded
   ```

3. If the server is exposed outside the cluster, verify the OAuth discovery endpoint is available:

   ```
   curl https://<YOUR_SERVER_URL>/.well-known/oauth-authorization-server
   ```

4. Connect with an MCP client that supports the MCP OAuth specification. The client should be redirected to your upstream identity provider for authentication.

5. Check the proxy logs for successful authentication:

   ```
   kubectl logs -n toolhive-system \

     -l app.kubernetes.io/name=weather-server-embedded
   ```

## Next steps[​](#next-steps "Direct link to Next steps")

* [Configure token exchange](/toolhive/guides-k8s/token-exchange-k8s.md) to let MCP servers authenticate to backend services
* [Deploy Redis session storage](/toolhive/guides-k8s/redis-session-storage.md) for production session persistence
* [Set up audit logging](/toolhive/guides-k8s/logging.md) to track authentication decisions and MCP server activity

## Related information[​](#related-information "Direct link to Related information")

* For conceptual background, see [Embedded authorization server](/toolhive/concepts/embedded-auth-server.md)
* For the other Kubernetes authentication approaches, see [Authentication and authorization](/toolhive/guides-k8s/auth-k8s.md)
* For multi-upstream provider support with vMCP, see [vMCP embedded authorization server](/toolhive/guides-vmcp/embedded-auth-server-vmcp.md)
* For a similar configuration pattern using token exchange, see [Configure token exchange](/toolhive/guides-k8s/token-exchange-k8s.md)
* For combining the embedded auth server with AWS STS, see [Combine embedded auth with AWS STS](/toolhive/integrations/aws-sts.md#combine-embedded-auth-with-aws-sts)
* For the complete field reference, see [MCPExternalAuthConfig](/toolhive/reference/crds/mcpexternalauthconfig.md)

## Troubleshooting[​](#troubleshooting "Direct link to Troubleshooting")

OAuth flow not initiating

* Verify the `MCPExternalAuthConfig` resource exists in the same namespace: `kubectl get mcpexternalauthconfig -n toolhive-system`
* Check that the `authServerRef.name` in your `MCPServer` matches the `MCPExternalAuthConfig` resource name
* Verify the upstream provider's client ID and redirect URI are correctly configured in the `MCPExternalAuthConfig`

Consumer reports `ExternalAuthConfigValidated=False`

When the referenced `MCPExternalAuthConfig` has its own `Valid` condition set to `False`, the consumer resource (`MCPServer`, `MCPRemoteProxy`, or `VirtualMCPServer`) mirrors that condition onto its own status with the same reason and message. Check both objects:

```
kubectl describe mcpserver -n toolhive-system <name>

kubectl describe mcpexternalauthconfig -n toolhive-system <authConfigName>
```

A reason like `EnterpriseRequired` on the consumer indicates the source `MCPExternalAuthConfig` is using a type (such as OBO) that requires Stacklok Enterprise. Fix the configuration on the `MCPExternalAuthConfig` and the consumer's mirrored condition clears on the next reconcile.

Token validation failures after restart

* Ensure you have configured `signingKeySecretRefs` and `hmacSecretRefs` with persistent keys
* Without these, ephemeral keys are generated on startup, invalidating all previously issued tokens

Upstream IdP redirect errors

* Verify the redirect URI configured in your upstream provider matches the ToolHive proxy's callback URL (typically `https://<YOUR_SERVER_URL>/oauth/callback`)
* Check that the upstream provider's issuer URL is accessible from within the cluster
* For OIDC providers, ensure the `/.well-known/openid-configuration` endpoint is reachable from the proxy pod
* If every authentication attempt fails immediately with a generic "authorization header required" error, check whether `issuer` has a path. See [Keep issuer path-free](#step-4-create-the-mcpexternalauthconfig-resource) above.

JWT signing key issues

* Verify signing key Secrets exist: `kubectl get secret -n toolhive-system auth-server-signing-key`
* Ensure the key format is correct (PEM-encoded RSA or EC private key)
* Check proxy logs for key loading errors: `kubectl logs -n toolhive-system -l app.kubernetes.io/name=weather-server-embedded`

OIDC configuration mismatch

* Ensure the MCPOIDCConfig issuer matches the `issuer` in your `MCPExternalAuthConfig`
* Verify the `resourceUrl` in `oidcConfigRef` matches the external URL of the MCP server

CIMD client not using CIMD (falling back to DCR)

* Verify `cimd.enabled: true` is set in the `MCPExternalAuthConfig` and the operator has reconciled the change: `kubectl describe mcpexternalauthconfig <name> -n toolhive-system`
* Confirm the discovery document advertises CIMD support:
  <!-- -->
  ```
  curl -s https://<YOUR_ISSUER_URL>/.well-known/oauth-authorization-server | \

    python3 -m json.tool | grep client_id_metadata_document
  ```
  <!-- -->
  Replace `<YOUR_ISSUER_URL>` with the `issuer` value from your `MCPExternalAuthConfig`. You should see `"client_id_metadata_document_supported": true`.
* Restart the proxy runner pod after updating an existing resource.

CIMD authentication failing with `invalid_client`

* The `client_id` field inside the fetched document must exactly match the URL used to fetch it.
* `token_endpoint_auth_method` must be `none` or omitted. When it names any other value (for example, `private_key_jwt`), the document must also publish a `token_endpoint_auth_methods_supported` list containing `none` so the server can negotiate down to `none`. Symmetric shared-secret methods (`client_secret_post`, `client_secret_basic`, `client_secret_jwt`) are always rejected with `invalid_client`.
* After unsupported entries are filtered out, `grant_types` must still include `authorization_code` and `response_types` must still include `code`.
* `redirect_uris` must be present and valid.

CIMD fetch failing (egress / timeout)

* The proxy runner pod needs outbound HTTPS access to the client's metadata URL (for example, `https://vscode.dev`). Check cluster egress policies.
* Fetches time out after five seconds. Ensure the metadata host is reachable from within the cluster.
