# Embedded authorization server

The embedded authorization server runs an OAuth authorization server within the vMCP process. It redirects users to one or more upstream identity providers (such as GitHub, Google, or Okta) for interactive authentication, stores the upstream tokens, and issues its own JWTs that the vMCP validates on subsequent requests. Combined with an outgoing strategy that forwards or exchanges those stored tokens, this bridges both authentication boundaries: the auth server handles incoming auth by issuing JWTs, while the outgoing strategy sends the stored upstream tokens to backends.

Use the embedded authorization server when your backend MCP servers call external APIs on behalf of individual users and no federation relationship exists between your identity provider and those services. It also supports OAuth 2.0 Client ID Metadata Documents (CIMD) and Dynamic Client Registration ([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)), so MCP clients can identify themselves or register automatically without manual client configuration in ToolHive.

info

This page covers the embedded auth server on a VirtualMCPServer. For conceptual background (the OAuth flow, token storage and forwarding, and when to use it), see [Embedded authorization server](/toolhive/concepts/embedded-auth-server.md). For the equivalent on individual MCPServer resources (single upstream provider), see [Set up the embedded authorization server in Kubernetes](/toolhive/guides-k8s/embedded-auth-server-k8s.md). For incoming and standalone backend authentication on vMCP, see [Authentication and authorization](/toolhive/guides-vmcp/authentication.md).

## How it works[​](#how-it-works "Direct link to How it works")

<!-- -->

When multiple upstream providers are configured, the auth server chains authorization flows sequentially. The user is redirected to each provider in order, and the auth server stores each provider's tokens before moving to the next. After the final provider completes, the auth server issues a single JWT to the client. To send each user through only the providers their groups need, see [Filter upstream providers by group](#filter-upstream-providers-by-group).

## How the references link by name[​](#how-the-references-link-by-name "Direct link to How the references link by name")

Two of the references around the embedded auth server link by matching string values, not by resource name, which is a common source of misconfiguration. The `MCPOIDCConfig` that validates incoming requests must carry the same `issuer` as `authServerConfig`, and each outgoing strategy's `providerName` (upstream injection) or `subjectProviderName` (token exchange, XAA) must match a `name` under `authServerConfig.upstreamProviders`.

<!-- -->

## Differences from the MCPServer embedded auth server[​](#differences-from-the-mcpserver-embedded-auth-server "Direct link to Differences from the MCPServer embedded auth server")

The embedded auth server uses the same configuration structure whether on a VirtualMCPServer or an MCPServer. The key differences for vMCP are:

* **Inline configuration:** The auth server config lives directly on the VirtualMCPServer resource under `authServerConfig`, rather than in a separate `MCPExternalAuthConfig` resource.
* **Multiple upstream providers:** vMCP supports multiple upstream providers with sequential authorization chaining, optionally filtered per user by group. MCPServer is limited to a single upstream provider.
* **Flexible outgoing strategies:** vMCP uses `upstreamInject` or `tokenExchange` with `subjectProviderName` to route stored tokens to the correct backends. MCPServer swaps the token automatically because it has a single upstream provider.

## Configure the embedded auth server[​](#configure-the-embedded-auth-server "Direct link to Configure the embedded auth server")

Add an `authServerConfig` block to your VirtualMCPServer. The configuration fields are the same as for the [MCPServer embedded auth server](/toolhive/guides-k8s/embedded-auth-server-k8s.md); see that guide for generating keys and creating Secrets.

VirtualMCPServer resource

```
spec:

  authServerConfig:

    issuer: https://auth.example.com

    signingKeySecretRefs:

      - name: auth-signing-key

        key: private-key

    hmacSecretRefs:

      - name: auth-hmac-key

        key: hmac-key

    tokenLifespans:

      accessTokenLifespan: 1h

      refreshTokenLifespan: 168h

      authCodeLifespan: 10m

    upstreamProviders:

      - name: github

        type: oauth2

        oauth2Config: { ... }

      - name: google

        type: oidc

        oidcConfig: { ... }
```

Signing keys and HMAC secrets

`signingKeySecretRefs` and `hmacSecretRefs` are technically optional. When omitted, the auth server auto-generates ephemeral keys on startup. This is convenient for development, but **tokens become invalid after pod restart**. JWTs can no longer be verified (signing keys) and authorization codes and refresh tokens can no longer be decoded (HMAC secrets), forcing all users to re-authenticate. Always configure persistent keys for production. See [Set up the embedded authorization server in Kubernetes](/toolhive/guides-k8s/embedded-auth-server-k8s.md) for key generation steps.

The issuer URL must use the `https://` scheme. The single exception is `localhost`, which can use `http://` for local development. For in-cluster deployments where traffic between the embedded auth server and other pods stays on a trusted network (for example, an in-cluster service mesh), you can opt in to an `http://` issuer on a non-localhost host by setting `insecureAllowHTTP: true`. The VirtualMCPServer controller rejects this combination at reconcile time with `AuthServerConfigValidated=False` if the flag is unset, so misconfiguration surfaces on the resource rather than crashing the pod at startup. Never set this for issuers reachable outside the cluster.

If the browser-facing authorization endpoint needs to be on a different host than the issuer (for example, behind an ingress that rewrites paths), set `authorizationEndpointBaseUrl` to override the `authorization_endpoint` in the OAuth discovery document. All other endpoints remain derived from `issuer`:

```
spec:

  authServerConfig:

    issuer: https://auth.internal.example.com

    authorizationEndpointBaseUrl: https://auth.example.com
```

Each upstream provider's `redirectUri` must share the browser-facing hostname: `authorizationEndpointBaseUrl` when it's set, otherwise `issuer`. `/oauth/callback` binds each login to the browser that started it with 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. Config validation logs a `WARN` at startup when the two hosts diverge.

Each upstream provider `name` must be a valid DNS label (lowercase alphanumeric and hyphens, max 63 characters). This name is what the [upstream token injection](#forward-a-stored-upstream-token-upstream-injection) and [token exchange](#exchange-a-stored-upstream-token-token-exchange) outgoing strategies reference to map backends to providers. For details on configuring OIDC vs OAuth 2.0 upstream providers, see [Using an OAuth 2.0 upstream provider](/toolhive/guides-k8s/embedded-auth-server-k8s.md#using-an-oauth-20-upstream-provider). When an upstream serves its endpoints with a certificate signed by an internal CA, set `caBundleRef` on the upstream's `oidcConfig` or `oauth2Config` to point at a ConfigMap containing the PEM bundle; see [Trust a private CA for the upstream provider](/toolhive/guides-k8s/embedded-auth-server-k8s.md#trust-a-private-ca-for-the-upstream-provider). The [complete example](#complete-example) below shows full provider configurations.

### Select the primary upstream provider[​](#select-the-primary-upstream-provider "Direct link to Select the primary upstream provider")

When multiple upstream providers are configured, Cedar reads JWT claims from the first upstream's access token by default. Pin a specific provider with `spec.authServerConfig.primaryUpstreamProvider`. The value must match one of the names in `upstreamProviders`; unresolvable values are rejected at admission with `AuthServerConfigValidated=False`:

```
spec:

  authServerConfig:

    issuer: https://auth.example.com

    primaryUpstreamProvider: okta

    upstreamProviders:

      - name: okta

        type: oidc

        # ...

      - name: github

        type: oauth2

        # ...
```

Deprecated location

`primaryUpstreamProvider` previously lived under `spec.incomingAuth.authzConfig.inline.primaryUpstreamProvider`. The old location is still read for backward compatibility, but the VirtualMCPServer controller emits a Warning event with reason `AuthzPrimaryUpstreamProviderDeprecated` whenever it reads the value. Move the field to `spec.authServerConfig.primaryUpstreamProvider` to clear the warning. Removal of the deprecated location is planned for the release after the deprecation cycle.

### Filter upstream providers by group[​](#filter-upstream-providers-by-group "Direct link to Filter upstream providers by group")

By default, every user signs in to every upstream provider in order. When different teams use different backends, for example only the sales team uses a Salesforce backend, add `spec.authServerUpstreamFilter` to choose the providers each user signs in to based on their groups. Users skip the providers their groups don't need, and ToolHive collects no tokens for those providers.

The first provider in `upstreamProviders` is mandatory: every user signs in to it, and ToolHive reads the user's groups from the identity it returns. The filter then picks which of the remaining providers to add:

VirtualMCPServer: filter upstream providers by group

```
spec:

  authServerConfig:

    issuer: https://auth.example.com

    upstreamProviders:

      - name: okta # mandatory first provider

        type: oidc

        # ...

      - name: github

        type: oauth2

        # ...

      - name: salesforce

        type: oauth2

        # ...

  authServerUpstreamFilter:

    claim: groups

    rules:

      - groups: ['engineering']

        upstreamProviders: ['github']

      - groups: ['sales', 'sales-ops']

        upstreamProviders: ['salesforce']
```

With this configuration, a member of `engineering` signs in to Okta and GitHub, a member of both `engineering` and `sales` signs in to all three, and a user in neither group signs in to Okta only.

| Field              | Description                                                                                                                                                                                                        |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `claim`            | Top-level claim that holds the user's groups as a string array. Defaults to `groups`. Nested claims, such as Keycloak's `realm_access.roles`, aren't supported.                                                    |
| `rules`            | Required, at least one rule. A rule matches when any of the user's groups appears in its `groups` list (exact, case-sensitive). The user signs in to every provider from every matching rule, in configured order. |
| `defaultUpstreams` | Providers to use when the claim is an empty array or no rule matches. When omitted, those users sign in to the first provider only.                                                                                |

Every name in `rules[].upstreamProviders` and `defaultUpstreams` must be a configured provider other than the first one. The filter requires `authServerConfig` with at least two upstream providers; the API server rejects the resource otherwise. The VirtualMCPServer controller checks the provider names at reconcile time and sets `AuthServerConfigValidated=False` when one is unknown or names the first provider.

Make sure the first provider emits the groups claim

ToolHive reads the claim from the first provider's validated ID token (OIDC) or userinfo response (OAuth 2.0). Groups that appear only in an access token aren't read. If the claim is missing, authorization fails, so enabling the filter before the provider emits the claim locks every user out. Configure the provider to include the claim, and request any scopes it requires, before you add the filter.

The filter only controls which upstream tokens ToolHive collects at sign-in. A backend that relies on a skipped provider's token has no token for that user, so pair the filter with [Cedar policies](/toolhive/guides-vmcp/authentication.md#authorize-requests) to control which backends and tools each user can access.

### Handle non-standard provider responses[​](#handle-non-standard-provider-responses "Direct link to Handle non-standard provider responses")

Not every provider returns tokens and identity in the standard shape. Three optional fields adapt the embedded auth server to providers that don't.

#### Tokens under non-standard paths[​](#tokens-under-non-standard-paths "Direct link to Tokens under non-standard paths")

Some OAuth 2.0 providers nest tokens instead of returning them at the top level (for example, GovSlack returns the access token at `authed_user.access_token`). Add a `tokenResponseMapping` block to the `oauth2Config` with dot-notation paths for `accessTokenPath`, `scopePath`, `refreshTokenPath`, and `expiresInPath`. See the [CRD reference](/toolhive/reference/crds/.md) for field details.

#### Identity in the token response[​](#identity-in-the-token-response "Direct link to Identity in the token response")

When an upstream returns user identity in the token response itself (Slack returns it at `authed_user.id`; Snowflake embeds it in the access-token JWT), set `identityFromToken` on the `oauth2Config` with gjson dot-notation paths for `subjectPath` (required), `namePath`, and `emailPath`. See [Extract identity from the token response](/toolhive/guides-k8s/embedded-auth-server-k8s.md#extract-identity-from-the-token-response) for the full pattern and trust-model caveats.

#### Stable subject for OIDC upstreams[​](#stable-subject-for-oidc-upstreams "Direct link to Stable subject for OIDC upstreams")

By default the embedded auth server keys users on the upstream ID token's `sub` claim. Some identity providers rotate `sub` per application, so the same user appears as a different principal across apps; the stable identifier lives in another claim (Microsoft Entra ID uses `oid`, some Okta custom auth servers expose a custom `uid` claim). Set `subjectClaim` on an upstream's `oidcConfig` to pin ToolHive to the stable claim:

```
upstreamProviders:

  - name: entra

    type: oidc

    oidcConfig:

      issuerUrl: 'https://login.microsoftonline.com/<TENANT_ID>/v2.0'

      clientId: '<CLIENT_ID>'

      # ...

      subjectClaim: oid
```

The value must be a single top-level claim name (letters, digits, underscores; must start with a letter or underscore). Changing it on a live deployment re-keys existing users, so treat it as immutable once users exist. The Entra walkthrough at [Connect ToolHive to Microsoft Entra ID](/toolhive/integrations/vmcp-entra-id.md) includes this setting.

## Register MCP clients with ToolHive[​](#register-mcp-clients-with-toolhive "Direct link to Register MCP clients with ToolHive")

This is the incoming registration flow: the MCP client is the OAuth client, and ToolHive's embedded authorization server is the authorization server. It is separate from [registering ToolHive with an OAuth provider](#register-toolhive-with-an-oauth-provider).

Enable CIMD to support clients that use the MCP specification's preferred registration mechanism. ToolHive continues to accept Dynamic Client Registration (DCR) requests. DCR remains available as a fallback for clients that support it but do not use CIMD. Client-side DCR does not require configuration.

### Choose a way to get a token[​](#choose-a-way-to-get-a-token "Direct link to Choose a way to get a token")

The embedded authorization server supports four ways a caller can get a ToolHive-issued token, each suited to a different trust relationship:

| Pattern                                                                               | Who authenticates                                        | Result                                                                       |
| ------------------------------------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------- |
| [Confidential DCR](#allow-confidential-dcr-clients)                                   | A client self-registers and gets a `client_secret`       | The client acts as itself; no delegation                                     |
| [Backend token exchange](/toolhive/concepts/backend-auth.md#token-exchange)           | A client presents its own token to a backend IdP         | The client's own identity, re-scoped to a backend audience                   |
| [RFC 8693 delegate-client exchange](/toolhive/guides-vmcp/delegate-agent-identity.md) | A pre-provisioned client presents a user's subject token | A token naming the user, with the agent recorded as the acting party (`act`) |
| [RFC 7523 JWT-bearer grant](/toolhive/guides-vmcp/accept-workload-assertions.md)      | No client at all; the assertion itself is the credential | A token naming the workload; no delegation, no pre-registered client         |

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

Add `cimd` under `spec.authServerConfig`:

VirtualMCPServer: CIMD configuration

```
spec:

  authServerConfig:

    cimd:

      enabled: true
```

| Field                   | Default | Description                                                         |
| ----------------------- | ------- | ------------------------------------------------------------------- |
| `cimd.enabled`          | `false` | Accept HTTPS URLs as `client_id` values and resolve their metadata. |
| `cimd.cacheMaxSize`     | `256`   | Optional maximum number of CIMD documents in the LRU cache.         |
| `cimd.cacheFallbackTtl` | `5m`    | Optional Go duration applied to every cached CIMD document.         |

The cache defaults are suitable for most deployments.

For an explanation of how ToolHive resolves CIMD documents and why the OAuth provider never sees the MCP 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")

The embedded authorization server caps the number of live DCR clients (default `10000`) and rate-limits `/oauth/register` (default 1 registration per second with a burst of 5, per replica). To change these limits, set `storage.maxDCRClients` and `registrationRateLimit` under `spec.authServerConfig`:

VirtualMCPServer: DCR limits

```
spec:

  authServerConfig:

    registrationRateLimit:

      maxTokens: 10

      refillPeriod: 10s

    storage:

      type: redis

      maxDCRClients: 25000

      redis:

        # ...
```

For client expiry rules, sizing guidance, and the HTTP responses at each limit, see [Limit Dynamic Client Registration](/toolhive/guides-k8s/embedded-auth-server-k8s.md#limit-dynamic-client-registration).

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

To configure a VirtualMCPServer for confidential DCR, set `allowConfidentialClientRegistration: true`:

VirtualMCPServer: confidential DCR

```
spec:

  authServerConfig:

    allowConfidentialClientRegistration: true
```

To force confidential registration for clients that use listed redirect URIs, add `forceConfidentialRedirectUris`:

VirtualMCPServer: force confidential for specific redirects

```
spec:

  authServerConfig:

    allowConfidentialClientRegistration: true

    forceConfidentialRedirectUris:

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

For the configuration's validation rules, security implications, and behavior, see [Allow confidential DCR clients](/toolhive/guides-k8s/embedded-auth-server-k8s.md#allow-confidential-dcr-clients).

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

To pre-provision a confidential client that performs RFC 8693 token exchange against the vMCP embedded auth server (typically a backend service you control), add an `inboundGrants.tokenExchange.delegateClients` entry:

VirtualMCPServer: delegate clients

```
spec:

  authServerConfig:

    inboundGrants:

      tokenExchange:

        delegateClients:

          - clientId: backend-exchange

            clientSecretRef:

              name: backend-exchange-client-secret

              key: client-secret

            audiences:

              - https://backend.example.com

            scopes:

              - backend-api:read
```

All four fields are required. Delegate clients are independent of `allowConfidentialClientRegistration`. Use an `https://` issuer for a delegate client. For local development, a plain-HTTP loopback issuer also works when you set `insecureAllowConfidentialOverLoopbackHTTP: true`; ToolHive rejects a plain-HTTP non-loopback issuer.

To revoke a delegate client, remove its entry from `delegateClients`. Each vMCP replica serves only the delegate clients its own configuration declares, and answers requests from any other delegate client in storage with `invalid_client`, as if the client were unknown. Access tokens already issued to the removed client stay valid until they expire. With Redis storage, the client's record stays in Redis; to delete it, remove the `<KEY_PREFIX>client:<CLIENT_ID>` key. During a rolling update, replicas still running the previous configuration answer a newly added delegate client with `invalid_client` until they're replaced.

To accept subject tokens minted by an external OIDC issuer, pair this with an `inboundGrants.tokenExchange.issuerPolicies` entry. 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")

If your MCP clients register via DCR with a narrowed `scope` value and then request additional scopes at `/oauth/authorize`, set `baselineClientScopes` so the embedded authorization server merges those scopes into every registered client's scope set:

VirtualMCPServer: baseline client scopes

```
spec:

  authServerConfig:

    baselineClientScopes:

      - openid

      - offline_access
```

If `scopesSupported` is set explicitly, every baseline value must appear in it. Otherwise, the server validates against its default scope set (`openid`, `profile`, `email`, `offline_access`). Keep the baseline narrow because every DCR-registered or CIMD-resolved client can request these scopes. See [Baseline scopes for DCR clients](/toolhive/concepts/embedded-auth-server.md#baseline-scopes-for-dcr-clients) for guidance on which scopes to include.

## Register ToolHive with an OAuth provider[​](#register-toolhive-with-an-oauth-provider "Direct link to Register ToolHive with an OAuth provider")

This section configures ToolHive's OAuth connection to an external provider. It is separate from incoming registration between MCP clients and ToolHive. Use a preconfigured client ID and secret, or add `dcrConfig` to register ToolHive at runtime when the provider supports RFC 7591. DCR works with both `oidcConfig` and `oauth2Config` upstreams.

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.

VirtualMCPServer: OIDC upstream with DCR

```
spec:

  authServerConfig:

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

    upstreamProviders:

      - name: keycloak

        type: oidc

        oidcConfig:

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

          dcrConfig: {}
```

For an OAuth 2.0 upstream, use an RFC 8414 discovery document to locate the provider's `registration_endpoint`:

VirtualMCPServer: upstream DCR discovery

```
spec:

  authServerConfig:

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

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

If the provider gives you a registration URL directly, use `registrationEndpoint` instead of `discoveryUrl`:

Use a direct registration endpoint

```
dcrConfig:

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

Set at most one of `discoveryUrl` or `registrationEndpoint`. A direct registration endpoint bypasses discovery, so keep the authorization endpoint, token endpoint, and scopes in the parent `oauth2Config`. With `dcrConfig`, ToolHive obtains the client ID and secret from the DCR response.

| Field                             | Description                                                                                                                                                                                                                                              |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `dcrConfig.discoveryUrl`          | HTTPS URL of an RFC 8414 or OpenID Connect discovery document.                                                                                                                                                                                           |
| `dcrConfig.registrationEndpoint`  | HTTPS URL of the RFC 7591 registration endpoint.                                                                                                                                                                                                         |
| `dcrConfig.initialAccessTokenRef` | Optional reference to a Secret whose value ToolHive sends as a bearer token when it calls the registration endpoint. Requires `name` and `key`.                                                                                                          |
| `dcrConfig.registrationProbe`     | How ToolHive checks the cached client at start: `tokenEndpoint` (default), `authorizationEndpoint`, or `none`; see [Re-register a dropped upstream client](/toolhive/guides-k8s/embedded-auth-server-k8s.md#re-register-a-dropped-upstream-client).      |
| `dcrConfig.rotationKey`           | Optional label (1-64 letters, digits, `.`, `_`, or `-`). Change it to force a fresh registration at the next start; see [Re-register a dropped upstream client](/toolhive/guides-k8s/embedded-auth-server-k8s.md#re-register-a-dropped-upstream-client). |

To stop sending `scope` on refresh token requests to providers that reject it, such as Salesforce, set `omitScopeOnRefresh: true` on the upstream's `oidcConfig` or `oauth2Config`. See [Omit scopes on token refresh](/toolhive/guides-k8s/embedded-auth-server-k8s.md#omit-scopes-on-token-refresh).

### 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. Allowed values are `none`, `client_secret_basic`, and `client_secret_post`. DCR clients continue to use the method negotiated at registration time.

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

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

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 provider's `oauth2Config.dcrConfig`. ToolHive sends the Secret value as a bearer token when it calls the registration endpoint:

Add an initial access token

```
dcrConfig:

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

  initialAccessTokenRef:

    name: dcr-initial-access-token

    key: token
```

## Validate the issued JWTs[​](#validate-the-issued-jwts "Direct link to Validate the issued JWTs")

When using the embedded auth server, configure `incomingAuth` to validate the JWTs it issues. Create an `MCPOIDCConfig` resource whose `issuer` matches `authServerConfig.issuer`, then reference it with `oidcConfigRef`. Note that `jwksAllowPrivateIP: true` is no longer needed when using the embedded auth server because JWKS retrieval is done in-process.

VirtualMCPServer resource

```
spec:

  incomingAuth:

    type: oidc

    oidcConfigRef:

      name: my-oidc-config

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

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

By default, upstream tokens and client credentials obtained through upstream DCR are stored in memory and lost on pod restart. When you use upstream DCR, a restart registers a new client with the provider and leaves the previous registration orphaned. For production, configure Redis storage by adding a `storage` block to `authServerConfig`. See [Redis session storage](/toolhive/guides-k8s/redis-session-storage.md) for a complete walkthrough.

## Forward user tokens to backends[​](#forward-user-tokens-to-backends "Direct link to Forward user tokens to backends")

The embedded auth server stores each user's upstream tokens after login. Three outgoing strategies send those stored tokens to backends. All three require the embedded auth server, because they read tokens it acquired during the user's interactive login. For backend strategies that do not depend on the embedded auth server (static credentials, plain token exchange, AWS STS, pass-through), see [Authentication](/toolhive/guides-vmcp/authentication.md#backend-authentication).

The same backend-linkage rules from [How the resources connect](/toolhive/guides-vmcp/authentication.md#how-the-resources-connect) still apply here: the token a strategy produces must satisfy the backend's own `oidcConfigRef`, and the `MCPExternalAuthConfig` is referenced either inline (`outgoingAuth.backends`) or through discovery (the backend's own `externalAuthConfigRef`). `upstreamInject`, for instance, only works when the backend accepts the upstream provider's token directly; when it doesn't, exchange the token instead.

### Forward a stored upstream token (upstream injection)[​](#forward-a-stored-upstream-token-upstream-injection "Direct link to Forward a stored upstream token (upstream injection)")

The `upstreamInject` strategy injects a user's stored upstream access token into outgoing requests to a backend. Use it when the backend accepts the same token the upstream provider issued (for example, a GitHub MCP server that accepts a GitHub access token).

Create an `MCPExternalAuthConfig` resource with the `upstreamInject` type. The `providerName` must match an upstream provider configured on the embedded authorization server:

MCPExternalAuthConfig resource

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

kind: MCPExternalAuthConfig

metadata:

  name: inject-github

  namespace: toolhive-system

spec:

  type: upstreamInject

  upstreamInject:

    providerName: github
```

Then reference it in the VirtualMCPServer's outgoing auth configuration:

VirtualMCPServer resource

```
spec:

  outgoingAuth:

    source: inline

    backends:

      backend-github:

        type: externalAuthConfigRef

        externalAuthConfigRef:

          name: inject-github
```

When a request reaches the `backend-github` MCPServer, vMCP replaces the `Authorization` header with the upstream access token stored for the `github` provider during the user's login flow. Backends not listed in the `backends` map receive unauthenticated requests.

note

The `providerName` must match a provider `name` in the auth server's `upstreamProviders` list.

Upstream injection is the typical pattern for remote SaaS backends, which are usually [`MCPServerEntry`](/toolhive/guides-vmcp/configuration.md#add-backends-to-a-group) resources that point vMCP at a hosted MCP server instead of running a pod. The entry has no auth surface of its own, so vMCP forwards the user's stored upstream token straight to the remote endpoint:

MCPServerEntry backend

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

kind: MCPServerEntry

metadata:

  name: backend-google

  namespace: toolhive-system

spec:

  groupRef:

    name: my-backends

  remoteUrl: https://mcp.example.com/mcp

  transport: streamable-http
```

Map it under `outgoingAuth.backends` by its name (`backend-google`), exactly as with the container backend above.

### Exchange a stored upstream token (token exchange)[​](#exchange-a-stored-upstream-token-token-exchange "Direct link to Exchange a stored upstream token (token exchange)")

You can combine the embedded authorization server with [token exchange](/toolhive/guides-k8s/token-exchange-k8s.md) by adding the `subjectProviderName` field to a `tokenExchange` config. This tells the token exchange middleware to use the stored upstream token from the named provider as the subject token for the [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) exchange, instead of the vMCP-issued JWT.

This is useful when a backend needs a token exchanged at the same identity provider that issued the upstream token. For example, if the embedded auth server acquires an Okta access token during login, you can exchange that token at a different Okta authorization server for a backend-scoped token:

MCPExternalAuthConfig resource

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

kind: MCPExternalAuthConfig

metadata:

  name: exchange-okta

  namespace: toolhive-system

spec:

  type: tokenExchange

  tokenExchange:

    tokenUrl: https://<YOUR_OKTA_DOMAIN>/oauth2/<AUTH_SERVER_ID>/v1/token

    clientId: <YOUR_CLIENT_ID>

    clientSecretRef:

      name: okta-exchange-client-secret

      key: client-secret

    audience: backend

    scopes:

      - backend-api:read

    subjectProviderName: okta
```

Without `subjectProviderName`, token exchange uses the vMCP-issued JWT as the subject token (the standalone [token exchange](/toolhive/guides-vmcp/authentication.md#federate-identity-with-token-exchange) pattern). With it, the exchange uses the raw upstream provider's access token, which the exchange endpoint can validate directly.

You can mix both strategies in the same vMCP deployment. For example, some backends can use `upstreamInject` for direct token forwarding while others use `tokenExchange` with `subjectProviderName` for exchanged tokens:

VirtualMCPServer resource

```
spec:

  outgoingAuth:

    source: inline

    backends:

      backend-github:

        type: externalAuthConfigRef

        externalAuthConfigRef:

          name: inject-github

      backend-okta-app:

        type: externalAuthConfigRef

        externalAuthConfigRef:

          name: exchange-okta
```

### Bridge separate trust domains (cross-application access)[​](#bridge-separate-trust-domains-cross-application-access "Direct link to Bridge separate trust domains (cross-application access)")

Experimental

Cross-application access (XAA) implements the IETF draft [`draft-ietf-oauth-identity-assertion-authz-grant`](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-identity-assertion-authz-grant) (ID-JAG), which is not yet a ratified standard. The CRD surface and runtime behavior may change in backward-incompatible ways as the draft evolves.

The `xaa` strategy handles the cross-domain case, where a backend trusts a different authorization server than the one that issued the client's token, and no federation is configured between them. XAA performs a two-step exchange on each backend call:

* **Step A ([RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693))**: vMCP exchanges the user's upstream ID token at their IdP for an ID-JAG assertion addressed to the target authorization server.
* **Step B ([RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523))**: vMCP presents the ID-JAG at the target authorization server, which returns an access token for the backend.

Use `xaa` when `tokenExchange` doesn't apply (the backend doesn't trust the client's IdP) and `upstreamInject` doesn't apply (the user has not previously authenticated to the backend's provider).

Create an `MCPExternalAuthConfig` with `type: xaa`. Store the IdP and target authorization server client secrets in Kubernetes Secrets:

MCPExternalAuthConfig resource

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

kind: MCPExternalAuthConfig

metadata:

  name: xaa-backend

  namespace: toolhive-system

spec:

  type: xaa

  xaa:

    # Step A: RFC 8693 token exchange at the user's IdP

    idpTokenUrl: https://idp.example.com/oauth2/v1/token

    # Optional in the schema, but strongly recommended: conformant IdPs

    # reject unauthenticated RFC 8693 token exchange requests

    idpClientId: <IDP_CLIENT_ID>

    idpClientSecretRef:

      name: xaa-idp-client-secret

      key: client-secret



    # Step B: RFC 7523 JWT Bearer grant at the target AS

    targetTokenUrl: https://target-as.example.com/oauth2/v1/token

    targetClientId: <TARGET_CLIENT_ID>

    targetClientSecretRef:

      name: xaa-target-client-secret

      key: client-secret



    # Required: URL of the target authorization server, used as the

    # ID-JAG audience claim

    targetAudience: https://target-as.example.com



    # Optional: RFC 8707 resource indicator sent in Step A. Some IdPs

    # (e.g. Okta's early ID-JAG implementation) require this even though

    # the draft marks it optional. Set it to the backend MCP server URL.

    targetResource: https://backend.example.com/mcp



    # Optional: scopes requested at both steps

    scopes:

      - backend-api:read



    # Required when the VirtualMCPServer has more than one upstream provider:

    # with multiple upstreams and this field unset, admission rejects the

    # backend with an AmbiguousSubjectProvider condition (the rest of the

    # server keeps reconciling). With a single upstream provider, the

    # controller auto-populates this field. Must match the name of an entry

    # under authServerConfig.upstreamProviders.

    subjectProviderName: my-idp
```

Attach the `MCPExternalAuthConfig` to the backend `MCPServer`, then use discovery mode on the VirtualMCPServer to pick it up automatically:

MCPServer resource

```
spec:

  externalAuthConfigRef:

    name: xaa-backend
```

VirtualMCPServer resource

```
spec:

  outgoingAuth:

    source: discovered
```

Inlining the reference directly in the VirtualMCPServer's `outgoingAuth.backends` block (the `source: inline` pattern used by other strategies) is structurally valid in the CRD, but XAA's secret mounting and `subjectProviderName` auto-population haven't been verified to behave the same way under `inline`. Use `discovered` until that's confirmed.

See the [MCPExternalAuthConfig reference](/toolhive/reference/crds/mcpexternalauthconfig.md) for the full `xaa` field list, including which fields are required.

Target client credentials are strongly recommended

The ID-JAG draft (§9.1) recommends confidential clients for Step B, and most target authorization servers reject unauthenticated JWT-bearer grants. Omitting `targetClientId` and `targetClientSecretRef` causes vMCP to log a warning at startup, and Step B typically fails at runtime.

## Skip token injection for public backends[​](#skip-token-injection-for-public-backends "Direct link to Skip token injection for public backends")

By default, the embedded auth server swaps the client's ToolHive JWT for the matching upstream token before forwarding the request to a backend, using [upstream token injection](#forward-a-stored-upstream-token-upstream-injection) or [token exchange](#exchange-a-stored-upstream-token-token-exchange). That assumes the backend itself requires authentication.

If the backend MCP server is public (for example, a documentation server with no auth of its own) but you still want clients to authenticate to vMCP, set `disableUpstreamTokenInjection: true` on `authServerConfig`. The embedded auth server still runs the OAuth flow for clients, but the proxy then **strips** the client's credential headers (`Authorization`, `Cookie`, and `Proxy-Authorization`) after the JWT is validated, so the backend receives an unauthenticated request.

VirtualMCPServer resource

```
spec:

  authServerConfig:

    issuer: https://auth.example.com

    disableUpstreamTokenInjection: true

    upstreamProviders:

      - name: github

        # ...
```

Use `outgoingAuth` with `headerInjection` (see [Static header injection](/toolhive/guides-vmcp/authentication.md#inject-a-static-credential-header-injection)) if the backend still needs a static credential such as an API key.

Incompatible with token exchange and AWS STS

The strip happens after JWT validation but before token-exchange and AWS STS middlewares would normally attach a new credential. Combining `disableUpstreamTokenInjection: true` with `tokenExchange` or `awsSts` on the same vMCP causes the embedded auth server to fail validation at startup. Use this flag only when the backend should remain unauthenticated.

## Complete example[​](#complete-example "Direct link to Complete example")

This example deploys a vMCP with an embedded auth server that authenticates users through GitHub and Google, then injects the GitHub access token into requests to a GitHub MCP server backend.

**Prerequisites:** Create Secrets for signing keys, HMAC keys, and upstream provider credentials following the steps in [Set up the embedded authorization server in Kubernetes](/toolhive/guides-k8s/embedded-auth-server-k8s.md). You need: `auth-signing-key`, `auth-hmac-key`, `github-client-secret`, and `google-client-secret`.

**Step 1:** Create an MCPGroup, OIDC config, and deploy the backend MCP server:

backends.yaml

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

kind: MCPGroup

metadata:

  name: my-backends

  namespace: toolhive-system

---

apiVersion: toolhive.stacklok.dev/v1beta1

kind: MCPOIDCConfig

metadata:

  name: my-oidc-config

  namespace: toolhive-system

spec:

  type: inline

  inline:

    issuer: https://auth.example.com

---

apiVersion: toolhive.stacklok.dev/v1beta1

kind: MCPServer

metadata:

  name: backend-github

  namespace: toolhive-system

spec:

  image: ghcr.io/github/github-mcp-server

  transport: streamable-http

  groupRef:

    name: my-backends
```

**Step 2:** Create the upstream token injection config:

auth-configs.yaml

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

kind: MCPExternalAuthConfig

metadata:

  name: inject-github

  namespace: toolhive-system

spec:

  type: upstreamInject

  upstreamInject:

    providerName: github
```

**Step 3:** Deploy the VirtualMCPServer:

virtualmcpserver.yaml

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

kind: VirtualMCPServer

metadata:

  name: my-vmcp

  namespace: toolhive-system

spec:

  groupRef:

    name: my-backends

  authServerConfig:

    issuer: https://auth.example.com

    signingKeySecretRefs:

      - name: auth-signing-key

        key: private-key

    hmacSecretRefs:

      - name: auth-hmac-key

        key: hmac-key

    tokenLifespans:

      accessTokenLifespan: 1h

      refreshTokenLifespan: 168h

      authCodeLifespan: 10m

    upstreamProviders:

      - name: github

        type: oauth2

        oauth2Config:

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

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

          clientId: <YOUR_GITHUB_CLIENT_ID>

          clientSecretRef:

            name: github-client-secret

            key: client-secret

          scopes:

            - repo

            - read:user

          userInfo:

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

            httpMethod: GET

            additionalHeaders:

              Accept: application/vnd.github+json

            fieldMapping:

              subjectFields:

                - id

                - login

              nameFields:

                - name

                - login

              emailFields:

                - email

      - name: google

        type: oidc

        oidcConfig:

          issuerUrl: https://accounts.google.com

          clientId: <YOUR_GOOGLE_CLIENT_ID>

          clientSecretRef:

            name: google-client-secret

            key: client-secret

          scopes:

            - openid

            - email

  incomingAuth:

    type: oidc

    oidcConfigRef:

      name: my-oidc-config

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

  outgoingAuth:

    source: inline

    backends:

      backend-github:

        type: externalAuthConfigRef

        externalAuthConfigRef:

          name: inject-github
```

**Step 4:** Verify the deployment:

```
# Check the VirtualMCPServer status

kubectl get virtualmcpserver -n toolhive-system my-vmcp



# Verify OAuth discovery is available

curl https://auth.example.com/.well-known/oauth-authorization-server
```

Connect with an MCP client that supports the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization). The client discovers the authorization server through protected resource metadata, then redirects you through each upstream provider for authentication. After completing the login flow, MCP tool calls to the GitHub backend automatically include your GitHub access token.

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

* [Connect ToolHive to an enterprise identity provider](/toolhive/integrations/vmcp-idp-overview.md) for IdP-specific walkthroughs (Entra ID, Okta) that use the embedded auth server
* [Set up audit logging](/toolhive/guides-vmcp/audit-logging.md) to track authentication decisions and request activity
* [Configure Redis Sentinel session storage](/toolhive/guides-k8s/redis-session-storage.md) so upstream tokens survive pod restarts

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

* [Authentication and authorization](/toolhive/guides-vmcp/authentication.md) for incoming and standalone backend authentication on vMCP
* [Embedded authorization server](/toolhive/concepts/embedded-auth-server.md) for conceptual background
* [Embedded auth server for MCPServer](/toolhive/guides-k8s/embedded-auth-server-k8s.md)
* [Token exchange in Kubernetes](/toolhive/guides-k8s/token-exchange-k8s.md)
* [VirtualMCPServer reference](/toolhive/reference/crds/virtualmcpserver.md)
* [MCPExternalAuthConfig reference](/toolhive/reference/crds/mcpexternalauthconfig.md)

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

CIMD-capable client uses DCR

DCR is the expected fallback for clients that do not support CIMD. If a client supports CIMD but still uses DCR:

* Verify `spec.authServerConfig.cimd.enabled: true` is set and the controller has reconciled the change: `kubectl describe virtualmcpserver <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
  ```
  <!-- -->
  The response should include `"client_id_metadata_document_supported": true`.
* Confirm the controller completed the automatic deployment rollout: `kubectl rollout status deployment/vmcp-<NAME> -n toolhive-system`.

CIMD authentication fails with `invalid_client`

* The `client_id` field in 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.
* After unsupported entries are filtered, `grant_types` must include `authorization_code` and `response_types` must include `code`.
* `redirect_uris` must be present and valid.

CIMD fetch fails because of egress or timeout errors

* The vMCP pod needs outbound HTTPS access to the client's metadata URL. Check your cluster's egress policies.
* Fetches time out after five seconds. Confirm that the metadata host is reachable from the vMCP pod.
