> ## Documentation Index
> Fetch the complete documentation index at: https://tyk-tt17611-iam-auth.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Token Exchange

> Configure RFC 8693 token exchange to replace the inbound token with a backend-scoped token before forwarding requests upstream.

## Availability

| Feature        | Editions   |
| :------------- | :--------- |
| Token exchange | Enterprise |

*Available from Tyk 5.14.0*

## Introduction

Token exchange ([RFC 8693](https://www.rfc-editor.org/rfc/rfc8693)) is a client authentication feature that replaces the inbound bearer token with a backend-scoped token before forwarding a request to the upstream service. Tyk Gateway presents itself as a confidential client to an external authorization server, exchanges the inbound token for one audienced to the upstream, and injects the result into the `Authorization` header. The inbound token never reaches the upstream service.

This approach addresses two common problems in multi-service architectures and MCP Gateway deployments:

* **Token audience mismatch**: An SSO or agent token issued for Tyk is not accepted by the upstream service, which expects a token carrying its own audience claim.
* **Audit trail continuity**: The raw inbound token is not forwarded to the upstream. The upstream receives a token scoped to its own audience, issued after the exchange, which keeps the token chain auditable at the authorization server level.

Token exchange runs in the middleware chain after scope enforcement and before the reverse proxy.

<Note>
  In open source deployments, token exchange does not execute at runtime. The inbound token is forwarded to the upstream unchanged, and an error is logged.
</Note>

***

## How it works

When a request arrives at a Tyk API with token exchange enabled:

1. Tyk validates the inbound bearer token using the configured `oauth2` security scheme.
2. If scope enforcement is enabled, Tyk checks the token's scopes against the operation's `security:` requirements.
3. Tyk reads the `iss` claim from the validated token and matches it against the `issuers` list on each configured provider.
4. Tyk POSTs an RFC 8693 exchange request to the matched provider's `tokenEndpoint`, presenting the inbound token as `subject_token`.
5. The authorization server returns a new access token. Tyk replaces the `Authorization` header with the exchanged token.
6. Tyk forwards the modified request upstream.

If no configured provider's `issuers` list matches the inbound token's `iss` claim, Tyk returns `403 Forbidden` with `error="no_matching_provider"`.

***

## Configure token exchange

Token exchange is configured under `tokenExchange` within the `oauth2` security scheme in your Tyk OAS API definition. The `oauth2` scheme must be enabled on the API; see [OAuth 2.0 (External IdP)](/api-management/authentication/oauth2-authentication). You must configure at least one provider.

<Note>
  Token exchange reads the `iss` claim from the inbound bearer token at request time. The `oauth2` scheme does not validate the token's JWT signature itself in Tyk 5.14.0. JWT authentication must be configured on the API so that the inbound token is verified before the exchange middleware runs. Without a JWT auth method configured, Tyk's auth chain will reject the request before token exchange is reached.
</Note>

### Minimal example

```yaml expandable theme={null}
x-tyk-api-gateway:
  server:
    authentication:
      enabled: true
      securitySchemes:
        idpAuth:
          enabled: true
          tokenExchange:
            enabled: true
            providers:
              - name: keycloak-prod
                issuers:
                  - https://idp.example.com/realms/demo
                tokenEndpoint: https://idp.example.com/realms/demo/protocol/openid-connect/token
                clientAuth:
                  method: client_secret_basic
                  clientId: tyk-gateway
                  clientSecret: env://EXCHANGE_CLIENT_SECRET
                defaultTarget:
                  audience: https://api.internal.example.com
                  scopes:
                    - api:read
                    - api:write
```

String fields in the API definition accept `env://`, `secrets://`, `vault://`, and `consul://` prefixes so sensitive values are not stored directly in the definition.

### Provider fields

Each entry in `providers` matches inbound tokens by `iss` claim and routes exchange requests to the corresponding token endpoint. Provider names must be unique, and issuer values must not overlap across providers.

| Field                     | Type             | Required | Description                                                                                                                                                                        |
| ------------------------- | ---------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`                    | string           | Yes      | Operator-assigned identifier used in log output. Must be unique across providers.                                                                                                  |
| `issuers`                 | array of strings | Yes      | Inbound token `iss` values routed to this provider. Must not overlap with issuers declared on other providers.                                                                     |
| `tokenEndpoint`           | string           | Yes      | Authorization server token endpoint. Must accept `grant_type=urn:ietf:params:oauth:grant-type:token-exchange`.                                                                     |
| `clientAuth.method`       | string           | No       | How Tyk authenticates to the token endpoint: `client_secret_basic` (default) sends credentials in the `Authorization` header; `client_secret_post` sends them in the request body. |
| `clientAuth.clientId`     | string           | Yes      | Client ID Tyk presents to the authorization server.                                                                                                                                |
| `clientAuth.clientSecret` | string           | No       | Client secret. Accepts `env://`, `secrets://`, `vault://`, `consul://` prefixes.                                                                                                   |
| `defaultTarget.audience`  | string           | No       | Default audience requested for the exchanged token. Applied when no per-operation override is set.                                                                                 |
| `defaultTarget.scopes`    | array of strings | No       | Default scopes requested. Applied when no per-operation override is set.                                                                                                           |
| `timeout`                 | duration string  | No       | Per-call timeout for requests to `tokenEndpoint`. Accepts values such as `"5s"` or `"500ms"`. Defaults to `"15s"`.                                                                 |
| `customParams`            | map              | No       | Additional form parameters appended to the exchange request. Standard RFC 8693 keys (`grant_type`, `subject_token`, `audience`, and others) are reserved and cannot be overridden. |

### Grant Type

**Available from Tyk 5.15.0.**

```yaml expandable highlight={3} theme={null}
providers:
  - name: corp-idp
    grantType: jwt-bearer
    issuers:
      - https://idp.example.com
    tokenEndpoint: https://idp.example.com/token
    clientAuth:
      method: client_secret_post
      clientId: tyk-gateway
      clientSecret: env://EXCHANGE_CLIENT_SECRET
    defaultTarget:
      audience: api://orders
      scopes:
        - Orders.Read
```

| Field       | Type   | Required | Description                                                                                                           |
| ----------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------------- |
| `grantType` | string | No       | `token-exchange` (default) sends the RFC 8693 token-exchange grant; `jwt-bearer` sends the RFC 7523 jwt-bearer grant. |

<Note>
  See [Microsoft Entra ID On-Behalf-Of](/api-management/authentication/token-exchange-microsoft-entra-obo) for a walkthrough of configuring `grantType: jwt-bearer`.
</Note>

Under `grantType: jwt-bearer`, the exchange request differs from RFC 8693 in three ways:

* Tyk Gateway sends the inbound token as `assertion`, not `subject_token`. None of the RFC 8693 subject-token fields are sent under this grant.
* No `audience` or `resource` wire parameter is ever sent. The audience is folded into `scope` instead, using the rendering rule below.
* Only `assertion` is a reserved `customParams` key, so a literal `audience` or `resource` custom parameter is forwarded if your authorization server expects one on this grant.

#### Scope Rendering

The configured `audience` and `scopes` describe the same logical target under both grants, but `jwt-bearer` renders them differently:

* Each scope that does not already contain a `/` is prefixed with `audience/`.
* A scope that already contains a `/` is treated as fully qualified and passed through verbatim.
* If no scopes are configured, no `scope` parameter is sent.

| Configured Target                                                                       | Scope Sent Under jwt-bearer                                           |
| --------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `audience: api://orders`, `scopes: [Orders.Read]`                                       | `scope=api://orders/Orders.Read`                                      |
| `audience: api://orders`, `scopes: [Orders.Read, https://graph.microsoft.com/.default]` | `scope=api://orders/Orders.Read https://graph.microsoft.com/.default` |
| No `audience`, `scopes: [read, write]`                                                  | `scope=read write`                                                    |
| `audience: api://orders`, no `scopes`                                                   | No `scope` parameter is sent                                          |

#### Example: Provider Without an Audience

An authorization server that implements plain RFC 7523, such as Keycloak, needs no `customParams`. Leave `audience` out and write the scopes exactly as your authorization server defines them:

```yaml expandable theme={null}
providers:
  - name: keycloak-jwt-bearer
    grantType: jwt-bearer
    issuers:
      - https://idp.example.com/realms/acme
    tokenEndpoint: https://idp.example.com/realms/acme/protocol/openid-connect/token
    clientAuth:
      method: client_secret_post
      clientId: tyk-gateway
      clientSecret: env://EXCHANGE_CLIENT_SECRET
    defaultTarget:
      scopes:
        - orders.read
        - orders.write
```

This sends `scope=orders.read orders.write`.

<Note>
  [RFC 7523 Section 3](https://www.rfc-editor.org/rfc/rfc7523#section-3) requires the inbound token's `aud` claim to already name this authorization server's token endpoint. That is a precondition on how the token was issued, not something this configuration controls.
</Note>

If your authorization server also expects an explicit downstream resource ([RFC 8707](https://www.rfc-editor.org/rfc/rfc8707)), pass it through `customParams`. Under `jwt-bearer`, `resource` is not reserved, so it is forwarded unchanged alongside the rendered `scope`:

```yaml theme={null}
customParams:
  resource: https://orders.example.com
```

#### Client Authentication

`clientAuth.method` supports `client_secret_basic` (default) and `client_secret_post` under both grants, plus `private_key_jwt`, which authenticates using a signed client-assertion JWT ([RFC 7523 Section 2.2](https://www.rfc-editor.org/rfc/rfc7523#section-2.2)) instead of a shared secret:

| Field               | Type   | Required    | Description                                                                                                                                                            |
| ------------------- | ------ | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `clientAuth.method` | string | No          | One of `client_secret_basic` (default), `client_secret_post`, or `private_key_jwt`.                                                                                    |
| `clientAuth.certId` | string | Conditional | Required when `method` is `private_key_jwt`. References a certificate already uploaded to Tyk Gateway's certificate store. Its private key signs the client assertion. |

The client assertion is short-lived (5 minutes) and its JOSE header carries the certificate's SHA-256 thumbprint (`x5t#S256`), so the authorization server can select the matching registered key.

<Note>
  `private_key_jwt` supports RSA-signed certificates only (RS256). EC keys are not supported.
</Note>

#### Step-Up Authentication

Under `grantType: jwt-bearer`, if the authorization server responds with `"error":"interaction_required"`, meaning the end user must sign in again, Tyk Gateway does not treat this as an ordinary exchange failure. It returns `401 Unauthorized` to the original caller with a `WWW-Authenticate: Bearer error="insufficient_claims"` header, relaying the authorization server's `claims` challenge (base64-standard-encoded) and `authorization_uri` when supplied. No upstream call is made and nothing is cached. A caller such as an MSAL-based client can then complete the required step-up, for example an Entra Conditional Access or MFA challenge, and retry.

This relay is scoped to the `jwt-bearer` grant. The same `interaction_required` error under the default `token-exchange` grant is treated as an ordinary `idp_error` failure. The relay records the `step_up_required` outcome described in [Observability](#observability).

#### Multi-Tenant Issuer Matching

Some authorization servers issue a different `iss` per tenant, with no way to enumerate every tenant up front. Two opt-in mechanisms cover this:

* An `issuers` entry prefixed with `regex:` is matched as a compiled regular expression against the inbound token's `iss` claim. Entries without the prefix keep exact-match semantics. Dispatch is deterministic: exact-match entries are checked first, across all providers; only if none match does Tyk Gateway try `regex:` entries, in provider order, first match wins.
* `tokenEndpoint` accepts Tyk's standard `$tyk_context.*` request-time variables, so one provider entry can serve many tenants from a single token endpoint template. For example, `$tyk_context.jwt_claims_tid` reads a `tid` claim from the validated JWT. The resolved endpoint is re-validated as an absolute `http(s)` URL at request time, and Tyk Gateway keys the token cache by the resolved endpoint so a cached token is never served to the wrong tenant.

<Note>
  A `regex:` issuer entry controls routing to a provider, not trust. Which tenants are allowed to authenticate is decided by the JWT authentication scheme's `allowedIssuers` list, which verifies the token's signature before token exchange runs. Pairing a permissive `regex:` entry with an empty or permissive `allowedIssuers` accepts any tenant of that authorization server, so set `allowedIssuers` deliberately.
</Note>

### Caching

Tyk can cache exchanged tokens in Redis to avoid a round-trip to the authorization server on every request. Configure caching under `cache` within a provider:

```yaml theme={null}
providers:
  - name: keycloak-prod
    ...
    cache:
      enabled: true
      mode: derived
      maxTimeout: 5m
      safetyMargin: 30s
```

| Field          | Type            | Default     | Description                                                                                                                                                                                                                                                                                                                                                                                                                       |
| -------------- | --------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled`      | boolean         | `false`     | Enables Redis-backed caching for this provider's exchanged tokens.                                                                                                                                                                                                                                                                                                                                                                |
| `mode`         | string          | `"derived"` | How the cache TTL is computed. `"derived"`: TTL is `min(expiresIn, inboundRemaining, maxTimeout) − safetyMargin`, where `expiresIn` is the exchanged token's lifetime, `inboundRemaining` is the inbound token's remaining life, and `maxTimeout` is an optional operator ceiling. `"static"`: TTL is `min(timeout, expiresIn) − safetyMargin`; the exchanged token's expiry still acts as an upper bound even in fixed-TTL mode. |
| `maxTimeout`   | duration string | -           | Optional operator ceiling on the cache TTL in `"derived"` mode (for example, `"5m"`). Has no effect in `"static"` mode.                                                                                                                                                                                                                                                                                                           |
| `timeout`      | duration string | -           | Fixed cache TTL in `"static"` mode (for example, `"2m"`). Still clamped by the exchanged token's expiry.                                                                                                                                                                                                                                                                                                                          |
| `safetyMargin` | duration string | `"30s"`     | Duration subtracted from the computed TTL to avoid serving near-expired tokens. Applies in both modes.                                                                                                                                                                                                                                                                                                                            |

***

## Per-operation override

The `defaultTarget` on a provider applies to all requests routed to that provider. To request a different audience or scope set for a specific operation, add an `exchange` block under the matching operation in `middleware.operations`:

```yaml theme={null}
x-tyk-api-gateway:
  middleware:
    operations:
      getInternalReport:
        exchange:
          enabled: true
          audience: https://reporting.internal.example.com
          scopes:
            - reports:read
```

| Field      | Type             | Description                                                                                                                                                                            |
| ---------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled`  | boolean          | Activates this per-operation override. When `false` or absent, the provider's `defaultTarget` is used.                                                                                 |
| `audience` | string           | Audience requested for this operation's exchanged token.                                                                                                                               |
| `scopes`   | array of strings | Explicit scope list for this operation. When empty and `enabled` is `true`, scopes are inferred from the operation's `security:` declaration; see [Scope inference](#scope-inference). |

***

## Per-MCP-primitive override

For MCP Gateway deployments, you can override the exchange target per primitive using `middleware.mcpTools`, `middleware.mcpResources`, or `middleware.mcpPrompts`:

```yaml theme={null}
x-tyk-api-gateway:
  middleware:
    mcpTools:
      create-report:
        exchange:
          enabled: true
          audience: https://reporting.internal.example.com
          scopes:
            - reports:write
```

The `exchange` block on a primitive has the same fields as the per-operation block. This lets you route different primitives to different downstream audiences within a single provider. For example, read tools to a read-only service and write tools to an elevated-privilege service.

***

## Scope inference

When a per-operation or per-primitive `exchange` block has `enabled: true` but `scopes` is empty, Tyk infers the scope list from the operation's or primitive's `security:` declaration. The scopes required by the operation's security requirements are sent as the requested scope to the authorization server, aligning the exchanged token's scope with what the upstream is expected to require (RFC 8693 §4.5.5).

If you prefer explicit control over the requested scopes, set `scopes` to a non-empty list.

***

## Known limitations

The following limitations apply in Tyk 5.15.0:

* **`private_key_jwt` key support**: `private_key_jwt` client authentication (used with the [`jwt-bearer` grant](#grant-type)) supports RSA-signed certificates only (RS256). EC keys are not supported.
* **Actor token delegation**: RFC 8693 defines an optional actor-token mechanism for representing a delegation chain (for example, a service acting on behalf of another service, which is itself acting on behalf of a user). Tyk does not currently implement this — token exchange always presents the gateway as the sole confidential client, with no actor token in the request. There is no `actorToken` configuration field.

***

## Observability

**Available from Tyk 5.15.0.**

Every token exchange decision emits an OpenTelemetry metric, a structured log line, a dedicated trace span and an audit event for authorization outcomes. You can see exchange volume and IdP latency, alert on error rates by provider, and trace a single request through the exchange step and the IdP round-trip it triggered.

### Metrics

See [Token Exchange Metrics](/api-management/metrics/default-metrics#token-exchange-metrics) for the full instrument and dimension reference.

### Structured logs

Tyk writes one JSON log line per exchange decision, using stable, `oauth2_`-namespaced field names so you can wire SIEM or alerting rules directly to them:

| Field                                               | Description                                                                                                                                                                           |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `trace_id`                                          | The OpenTelemetry trace ID for the request. Matches the `trace_id` on the gateway access log line for the same request, so you can join the two.                                      |
| `oauth2_api_id`                                     | The Tyk API ID the exchange ran on.                                                                                                                                                   |
| `oauth2_provider`                                   | The provider `name` that matched the inbound token.                                                                                                                                   |
| `oauth2_exchange_outcome`                           | One of `ok`, `idp_error`, `misconfig`, `no_matching_provider`, or `step_up_required` (`jwt-bearer` grant only).                                                                       |
| `oauth2_exchange_cache_hit`                         | Whether the exchanged token was served from cache.                                                                                                                                    |
| `duration_ms`                                       | Time taken to obtain the exchanged token, in milliseconds.                                                                                                                            |
| `oauth2_idp_error` / `oauth2_idp_error_description` | The authorization server's error code and description. Present only on an `idp_error` outcome, and length-capped so an oversized or malicious IdP response cannot bloat the log line. |
| `oauth2_subject_azp`                                | The `azp` (authorized party) of the inbound token — the client application the token was issued to.                                                                                   |
| `oauth2_exchanged_azp`                              | The `azp` of the exchanged token, when the authorization server returns one.                                                                                                          |
| `oauth2_audience`                                   | The audience requested for the exchanged token.                                                                                                                                       |
| `oauth2_scopes_requested`                           | The scopes requested for the exchanged token.                                                                                                                                         |

```json theme={null}
{
  "level": "info",
  "msg": "oauth2 token exchange",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "oauth2_api_id": "a1b2c3d4e5f6",
  "oauth2_provider": "keycloak-prod",
  "oauth2_exchange_outcome": "ok",
  "oauth2_exchange_cache_hit": false,
  "duration_ms": 42,
  "oauth2_subject_azp": "agent-frontend",
  "oauth2_audience": "https://api.internal.example.com",
  "oauth2_scopes_requested": ["api:read", "api:write"]
}
```

Following [OWASP's logging guidance](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html) best practices, Tyk omits the inbound token's `jti` and `sub` claims from the log line.

The `azp` fields above already answer which application acted, for which audience, with what scopes, and with what outcome, without exposing an end user's identity.

### Audit events

Tyk fires an audit event for every exchange decision that represents an authorization outcome:

| Outcome                                      | Audit event                                                                                       |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `ok` (including cache hits)                  | `OAuth2ExchangeSucceeded`                                                                         |
| `idp_error`                                  | `OAuth2ExchangeFailed`                                                                            |
| `no_matching_provider`                       | `OAuth2ExchangeFailed`                                                                            |
| `misconfig`                                  | Not audited                                                                                       |
| `step_up_required` (`jwt-bearer` grant only) | Not audited — this is an expected control-flow event relaying a caller-side action, not a failure |

A cache hit still fires `OAuth2ExchangeSucceeded`, because a backend call is still being made on the subject's behalf even though the exchanged token itself came from cache rather than a fresh round-trip to the authorization server.

<Note>
  `misconfig` does not produce an audit event. An unresolvable client secret or other configuration fault is an operational error, not an authorization decision made by the IdP, so it's surfaced only through the metric and log line.
</Note>

The audit event's metadata carries the same `oauth2_`-namespaced fields as the structured log line: the provider, outcome, audience, requested scopes, the `azp` client identifiers, and the IdP error code and description when applicable. As with the logs, no raw token material, `jti`, or `sub` ever appears in audit metadata.

### Distributed tracing

Token exchange opens a dedicated `oauth2.exchange` span for every attempt, nested inside the request's existing trace after the inbound token is identified and before the request reaches the upstream proxy. This is a real child span from the OpenTelemetry tracer, not attributes stamped onto the inbound request span, so the exchange step shows up as its own node in the trace.

The span carries three attributes, using the same bounded values as the metric labels and log fields:

| Attribute                      | Description                                                                                                     |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------- |
| `oauth2.exchange.provider`     | The matched provider name.                                                                                      |
| `oauth2.exchange.outcome`      | One of `ok`, `idp_error`, `misconfig`, `no_matching_provider`, or `step_up_required` (`jwt-bearer` grant only). |
| `oauth2.exchange.cache_status` | `hit` or `miss`.                                                                                                |

On a cache miss, the call to the authorization server's token endpoint appears as a child HTTP client span under `oauth2.exchange`, since Tyk's IdP client is already traced. On a cache hit, there's no such child span at all — the trace shows the absence of a round-trip. This makes it easy to tell, at a glance, whether a given request paid the cost of a live IdP call or was served entirely from cache. Put together, a single trace shows the inbound request, the exchange decision, the IdP round-trip (or its absence), and the upstream call, in order.

***

## Token exchange and upstream auth

<Note>
  Do not configure `upstream.authentication.oauth` alongside token exchange. Token exchange replaces the `Authorization` header with the exchanged token before the reverse proxy; it is the upstream credential. The `upstream.authentication` block is for a separate scenario where Tyk authenticates to the upstream using its own static credentials, independent of any inbound token. Configuring both will produce a conflict.
</Note>
