Skip to main content

Overview

auth_type: "token_exchange" lets each caller reach an upstream MCP server as themselves, without Bifrost storing a credential per user for that server. With the default (direct) profile, described on this page, on every tool call Bifrost takes the caller’s own identity-provider (IdP) token and exchanges it, via RFC 8693 Token Exchange or the equivalent on-behalf-of grant, for a short-lived token scoped to that MCP server’s audience. When the request carries no IdP token but was authenticated through the caller’s own Bifrost sign-in, Bifrost exchanges the access token from that sign-in instead; see Identity requirements. The exchanged token is sent upstream; the caller’s original token never is. The Cross App Access (id_jag) profile works differently: the subject is the user’s SSO session rather than their access token, and the upstream server’s own authorization server issues the token. This is strictly delegated: there is no shared service-account fallback. A caller with neither an identity token nor a Bifrost sign-in of their own cannot use a token_exchange server. See Identity requirements.
Token exchange is available in Bifrost v2.0.0 and above.
Requires an enabled SCIM identity provider (enterprise). Token exchange runs against whichever IdP handles your SSO, using its token endpoint. See Prerequisites.
The /mcp gateway’s own authentication mode is a separate, global setting from any MCP client’s auth_type (see Gateway Authentication → Authentication Modes), and it decides how callers reach a token_exchange server through /mcp. In strict oauth mode, /mcp accepts only Bifrost-issued OAuth tokens, so a caller cannot send their own identity-provider token there. Callers who connect in user mode still reach token_exchange servers: Bifrost exchanges the access token from their Bifrost sign-in. Virtual-key and session-mode tokens carry nothing to exchange. Use headers or both if your callers send their identity-provider token to /mcp directly.
If the upstream MCP server doesn’t trust your SCIM identity provider’s issuer — most third-party SaaS services — direct token exchange cannot work there. If the service supports Okta Cross App Access, use the Cross App Access (id_jag) profile; otherwise use Per-User OAuth. This auth type is only valid for HTTP connections. It is refused on an SSE or STDIO client, because each call opens its own connection: the API answers 400, and a config.json client is skipped at boot with an error naming it.

Prerequisites

Token exchange needs an application registered at your identity provider with the token-exchange (or on-behalf-of) grant enabled and permission to mint tokens for your MCP servers’ audiences. Each MCP client picks which application performs its exchange via token_exchange.use_idp_credentials:
  • use_idp_credentials: false (default) — a dedicated application, separate from your SSO login application, configured per MCP client via token_exchange.client_id/client_secret. This is the right default for most providers: it keeps each MCP server’s OBO grant scoped independently and limits what a leaked secret can reach.
  • use_idp_credentials: true — reuses your SSO login application’s own credentials instead; client_id/client_secret are then ignored.
Consult your IdP’s documentation for how to register the exchange application; the exact grant name and setup steps vary by vendor:
Microsoft Entra ID requires use_idp_credentials: true. Entra’s on-behalf-of grant only accepts an assertion whose audience matches the application performing the exchange, and Bifrost’s SSO login flow always requests a token self-audienced to the SSO application. A dedicated exchange application — a different application than the one your admins signed into Bifrost with — can never receive an assertion it’s allowed to use, so the exchange fails on every call. See Microsoft Entra ID: known setup gotchas below.This isn’t a Bifrost limitation: RFC 8693 servers (Okta, Auth0, Keycloak) authorize the requesting client for a target audience independently of whose token it presents — that’s what lets a dedicated exchange app work there. Entra’s OBO grant predates RFC 8693 and has no equivalent separate authorization step; it enforces the audience match directly on the assertion instead, so the requesting client and the token’s original audience must be the same application. Microsoft’s own OBO reference confirms this is enforced, not incidental: “This token must have an audience (aud) claim of the app making this OBO request… Applications can’t redeem a token for a different app.” (Microsoft identity platform and OAuth2.0 On-Behalf-Of flow)
Bifrost derives the token endpoint and picks the correct grant shape automatically from your enabled SCIM provider — you never configure an endpoint or grant type directly.
Okta specifically ties an audience to the Authorization Server that issues it — one Authorization Server, one audience. Okta’s own documented best practice is a dedicated Custom Authorization Server per protected resource, separate from the one your SSO login uses. If that’s your setup, three additional things are required beyond the app registration above — see Okta: per-resource Authorization Server below. Entra and Auth0 don’t have this constraint: both use a single tenant-wide token endpoint regardless of target resource, so nothing extra is needed for them.

Okta: per-resource Authorization Server

Skip this section if your exchange application’s audience is registered on the same Authorization Server your SSO login uses — the defaults just work. If you followed Okta’s own guidance and created a separate Custom Authorization Server for the resource (e.g. one per MCP server, or one shared “internal APIs” server distinct from your SSO login’s server), all three of the following are required:
  1. Point the exchange at that Authorization Server explicitly. Set token_exchange.authorization_server_url to its issuer URL (e.g. https://your-domain.okta.com/oauth2/your-auth-server-id). Without this, Bifrost sends the exchange request to the same Authorization Server your SSO login uses — which has never heard of an audience registered on a different one, and Okta rejects it with invalid_target: Token Exchange requests must include a valid audience of the authorization server.
  2. Register your SSO login’s Authorization Server as a Trusted Server on the resource’s Authorization Server: Security → API → (the resource’s Authorization Server) → Trusted servers → Add Server, and add the Authorization Server your SSO login uses (often default). Without this, Okta rejects the caller’s identity token as an untrusted subject with invalid_request: 'subject_token' is invalid — the audience and endpoint can be entirely correct and this still fails, since it’s a separate cross-server trust check.
  3. Define a custom scope on that Authorization Server and include it in token_exchange.scopes. Standard OIDC scopes (openid, profile, email, offline_access) aren’t valid for a service-app token exchange — Okta rejects the request with invalid_scope: ... 'scope' must be provided if none is set, or rejects an OIDC scope outright. Add a scope under Security → API → (the resource’s Authorization Server) → Scopes → Add Scope (e.g. your-resource.access), grant it to the exchange application in that Authorization Server’s Access Policy, and list it in scopes.
Also check that Okta’s DPoP (sender-constrained tokens) requirement is off for the exchange application — Bifrost’s token exchange sends plain bearer tokens, not DPoP-proofed ones. If your org enforces DPoP by default, disable it specifically for this application under its General settings, or the token request fails with invalid_dpop_proof: The DPoP proof JWT header is missing.

Microsoft Entra ID: known setup gotchas

Entra’s OBO flow surfaces several sharp edges that don’t show up with Okta/Auth0/Keycloak. All of these were hit while setting up a real tenant end-to-end; fix them in this order if you see the matching error.
  1. Set token_exchange.use_idp_credentials: true. This is not optional for Entra — see the warning in Prerequisites above for why a dedicated exchange application structurally cannot work. client_id/client_secret are ignored once this is set; leave them unset.
  2. Token issuer mismatch — oidc: id token issued by a different provider. Whether an app’s tokens use the v1 issuer (https://sts.windows.net/<tenant>/) or the v2 issuer (https://login.microsoftonline.com/<tenant>/v2.0) is controlled by the resource app’s manifest, not by which endpoint you called. If your MCP server validates against the v2 issuer (the standard OIDC discovery shape) but gets v1 tokens, open the resource app’s registration → Manifest → set "requestedAccessTokenVersion": 2 under the api block → Save.
  3. Audience mismatch — oidc: expected audience "api://..." got ["..."]. Once an app issues v2 tokens, its access tokens carry the app’s client ID (bare GUID) in aud, not the api://... Application ID URI you registered — even though you requested the scope using the URI form. Set token_exchange.audience (and the MCP server’s own audience-validation config) to the bare GUID, not api://<guid>.
  4. AADSTS240002: Input id_token cannot be used as 'urn:ietf:params:oauth:grant-type:jwt-bearer' grant. The assertion Entra’s OBO endpoint accepts must be an access token, never an id_token. Bifrost’s SSO session already stores the right one for real caller traffic; this only bites when hand-crafting a token for manual testing — if you hit it, you grabbed the id_token instead of the access_token.
  5. AADSTS501461: AcceptMappedClaims is only supported for a token audience matching the application GUID.... If your SSO app has custom claims mapping enabled (acceptMappedClaims: true in its manifest — typically on if you’re mapping roles/groups claims for SCIM attribute mappings), requesting an access token audienced to that app’s own api://<guid> Application ID URI is rejected outright. This only comes up when manually minting a test token against the SSO app’s own scope (e.g. <client-id>/access_as_user, not api://<client-id>/access_as_user — Entra treats an app’s own client ID as an implicit alternate resource identifier alongside its App ID URI, and that form isn’t subject to the same restriction); Bifrost’s own runtime exchange isn’t affected.
  6. Adding a scope silently drops resource access. Entra’s .default scope (what audience builds by default) cannot be combined with other delegated scopes — Microsoft’s own OBO docs are explicit about this (AADSTS70011 otherwise). Bifrost handles the one documented exception automatically: scopes: ["offline_access"] combines with the default (<audience>/.default offline_access) rather than replacing it. Any other scope you configure fully replaces the default instead, per Entra’s own rule — there is no way to request .default alongside a named custom scope in the same call. If you need narrower, specific permissions rather than everything .default grants, expose named scopes on the resource app (Expose an API → Add a scope) and list only those in scopes, without .default at all.

Setup

Each MCP client scopes its own exchange: which resource (audience) the token is minted for, and which IdP application performs the exchange.
MCP client form with Auth Type set to Token Exchange, showing the audience, exchange client ID/secret, and scopes fields
  1. Navigate to MCP Gateway in the sidebar
  2. Click New MCP Server
  3. Pick HTTP as the connection type, fill in the Connection URL
  4. Set Auth Type to Token Exchange (On-Behalf-Of) — only shown when an identity provider is configured
  5. Fill in:
    • Audience — the resource identifier this server is registered as at your identity provider (e.g. api://jira-mcp for Okta/Auth0/Keycloak). For Microsoft Entra ID, this is the resource app’s Application (client) ID — a bare GUID, not the api://... Application ID URI shown under “Expose an API”; see Audience mismatch below for why
    • Exchange application — Dedicated application (default) or Identity provider application. Pick the latter, which reuses your SSO login application’s credentials, if your provider requires it — Microsoft Entra ID always does; see Prerequisites
    • Exchange Client ID (required) / Exchange Client Secret (optional for public clients) — only shown when Dedicated application is selected
    • Authorization Server URL (optional) — only needed if this audience is registered on a different Authorization Server than your SSO login uses; see Okta: per-resource Authorization Server
    • Scopes (optional, comma-separated) — include offline_access where your identity provider supports it, so exchanges issue refresh tokens and the retained admin discovery credential stays self-renewing. For Microsoft Entra ID specifically: offline_access is the only additional scope that combines with the audience’s default resource access — leave it as the sole entry to keep both; any other scope here replaces the default resource scope entirely rather than adding to it, and Entra rejects a .default + custom-scope combination outright — if you need narrower, specific permissions instead of the audience’s default access, list only named resource scopes here (e.g. api://jira-mcp/access_as_user), with no .default entry at all
  6. Click Create. Bifrost exchanges the access token from your own Bifrost sign-in, verifies the upstream connection, and discovers tools. If your sign-in has expired, the create fails and asks you to sign in to Bifrost again

How it works

  1. The caller sends a request carrying their own identity-provider token (Authorization: Bearer <token>), or one authenticated through their own Bifrost sign-in: a dashboard session, a Bifrost Edge or Bifrost CLI session, or a user-mode /mcp OAuth token.
  2. Bifrost checks its in-memory cache for an exchanged token already minted for this (identity, mcp_client) pair.
  3. On a miss, Bifrost exchanges the caller’s token at the identity provider’s token endpoint, scoped to the client’s configured audience. A request without an identity-provider token of its own exchanges the access token from the caller’s sign-in, which Bifrost refreshes first when it has expired or is about to.
  4. The exchanged token is used on an ephemeral upstream connection for that call only — nothing about it is written to the database.
  5. The cache holds the exchanged token until shortly before it expires, so repeat calls from the same caller don’t re-exchange every time.
If the identity provider rejects the exchange (the caller’s token is invalid, revoked, or the exchange application lacks permission for the audience), the tool call fails with a message naming the identity-provider error. If it rejects the access token from a Bifrost sign-in, Bifrost refreshes the sign-in once and retries the exchange once before failing. When the sign-in can no longer be refreshed, the tool call fails with:
There is no interactive flow to complete: the caller fixes their credential, or signs in to Bifrost again, and retries. If the upstream MCP server rejects an exchanged token with 401 or 403, Bifrost discards it and exchanges again. A tool that is safe to retry is retried once with the new token. For one that is not, the error surfaces right away while the exchange runs in the background, so later calls use the new token once it succeeds. See Auth failure recovery.

Verification

Bifrost needs to prove the exchange works once before serving traffic: exchange the admin’s token, connect to the upstream server, and discover its tools. There is no manual token to paste: verification always runs as the signed-in admin. Bifrost exchanges the identity-provider token on the request or, when the request carries none, the access token from the admin’s own Bifrost sign-in, refreshed first if needed. From the dashboard, it is always the sign-in.
  • pending_verification — shown for a newly created or config.json-declared client with no discovered tools yet. Click Verify as me on the client sheet.
  • needs_reauth — shown when the retained admin discovery credential (see below) has died and needs repair. Click Re-verify as me.
Both buttons call the same endpoint, POST /api/mcp/client/{id}/verify-exchange, with no request body. Token exchange client sheet showing the Verify as me button in pending_verification state
Verification requires an identity-authenticated session or request: an SSO dashboard session, or a request bearing your own IdP token. A request authenticated with an API key has nothing to exchange: verify-exchange returns 400 asking you to sign in with your identity provider, and a client created that way starts in pending_verification. If your sign-in has expired, verify-exchange returns 400 and creating the client returns 422, both asking you to sign in to Bifrost again. A rejected or failed exchange returns 422 with the identity-provider error.

Admin discovery credential

The token exchanged during verification is retained as a client-level admin discovery credential, used only for periodic tool-list refresh and never for end-user tool calls, which always exchange the caller’s own token or Bifrost sign-in. A client with Per-Caller Discovery on retains nothing: verification only checks the exchange, each caller’s tools are discovered with their own exchanged token, and Re-verify as me has nothing to repair (verify-exchange returns 400 once the client is verified). It renews automatically via the refresh token your identity provider issues alongside the exchange, provided your configured scopes include offline_access (or your provider’s equivalent). Without a refresh token, the admin credential expires within its own lifetime and the client shows needs_reauth until an admin clicks Re-verify as me. End-user tool calls are unaffected either way — only the periodic tool-list refresh pauses. Token exchange client sheet showing the needs_reauth badge and the Re-verify as me repair button

Identity requirements

Unlike per-user OAuth and per-user headers, token exchange has no virtual-key-only path. Every call needs a subject to exchange: the caller’s own validated identity-provider token, or the access token from the caller’s own Bifrost sign-in. A virtual key alone establishes governance (budgets, rate limits, tool scoping) but carries nothing to exchange.
  • Authorization: Bearer <IdP JWT>: validated against your SCIM identity provider, then used as the exchange subject.
  • A dashboard SSO session (cookie): the access token from your sign-in is used automatically; no header needed.
  • A Bifrost Edge or Bifrost CLI session: the access token from your sign-in is used, including when a virtual key is attached to the request.
  • A Bifrost-issued /mcp OAuth token in user mode (see Identity modes at consent): the access token from your sign-in is used.
  • A virtual key alone (even one owned by a user), a virtual-key or session-mode /mcp OAuth token, or an API key: resolves identity and governance, but a tool call against a token_exchange server fails: subject_token_missing.
  • An identity-provider token that fails validation: the tool call fails and names the rejected token; Bifrost does not fall back to your sign-in.
A token on the request always wins: Bifrost uses your sign-in only when the request carries no identity-provider token, and only for the user who signed in. The sign-in is your sign-in to Bifrost in the browser, the same one Bifrost Edge and Bifrost CLI sign-in go through. Bifrost exchanges its access token, never its ID token, and refreshes it when it has expired or is about to, using the refresh token from that sign-in (keep offline_access, the default, in your SSO login scopes). When none of your sign-ins can be refreshed any more (the refresh token expired or was revoked, or you signed out everywhere), tool calls fail saying your Bifrost sign-in has expired (see How it works for the message). Signing in again in the browser fixes it. Signing out of one browser ends only that sign-in, and tokens Bifrost already exchanged for you keep working until they expire. Sending both an identity token and a virtual key is fine: dual_credential_conflict_behavior decides which one governance attributes the request to, while the identity token is used for the exchange either way.
Using a stored sign-in is the default. Before v2.4.0, calls from Bifrost Edge and Bifrost CLI sessions and /mcp user tokens with no identity-provider token failed with subject_token_missing. From v2.4.0 they succeed as long as the user is signed in, so make sure your processes account for this default. A dashboard session, a Bifrost Edge or Bifrost CLI session, or a Bifrost-issued /mcp user token, including a Cross App Access token, is enough on its own to reach token_exchange servers as that user, within their tool permissions. Treat a stolen one as access to those servers too. Because Bifrost refreshes the sign-in itself, this keeps working after the original token expires, until the identity provider rejects the refresh, that sign-in is signed out, or it reaches 30 days. To cut a user off, delete them in Bifrost or remove their access to the MCP client.

Configuration reference

client_id and client_secret are redacted in API responses, same as other MCP credential fields.

Try it yourself

examples/mcps/token-exchange-demo-server is a runnable MCP server for testing this end-to-end against a real identity provider (Okta, Entra, Keycloak, Auth0, or any generic OIDC provider). It validates every request’s bearer token via real OIDC discovery and JWKS signature verification — no shortcuts — and exposes a whoami tool that echoes back the caller’s identity claims, so you can confirm each caller’s own identity reached the upstream server.

Next Steps