diff --git a/app/_how-tos/gateway/configure-oidc-with-auth-token-exchange.md b/app/_how-tos/gateway/configure-oidc-with-auth-token-exchange.md
index c02b35523b..065c692762 100644
--- a/app/_how-tos/gateway/configure-oidc-with-auth-token-exchange.md
+++ b/app/_how-tos/gateway/configure-oidc-with-auth-token-exchange.md
@@ -11,6 +11,8 @@ related_resources:
url: /gateway/authentication/
- text: Token exchange in OIDC
url: /plugins/openid-connect/#token-exchange
+ - text: Configure multi-IdP with a trusted issuer registry
+ url: /how-to/configure-oidc-with-multi-idp/
- text: OpenID Connect tutorials
url: /how-to/?query=openid-connect
diff --git a/app/_how-tos/gateway/configure-oidc-with-multi-idp.md b/app/_how-tos/gateway/configure-oidc-with-multi-idp.md
new file mode 100644
index 0000000000..ac3fc425cb
--- /dev/null
+++ b/app/_how-tos/gateway/configure-oidc-with-multi-idp.md
@@ -0,0 +1,173 @@
+---
+title: Configure OpenID Connect with multiple IdPs using a trusted issuer registry
+permalink: /how-to/configure-oidc-with-multi-idp/
+content_type: how_to
+description: Learn how to configure the OpenID Connect plugin to validate tokens from multiple identity providers using a trusted issuer registry.
+
+related_resources:
+ - text: OpenID Connect in {{site.base_gateway}}
+ url: /gateway/openid-connect/
+ - text: Authentication in {{site.base_gateway}}
+ url: /gateway/authentication/
+ - text: Multi-IdP token validation at the gateway layer
+ url: /plugins/openid-connect/multi-idp/
+ - text: Configure OpenID Connect with token exchange
+ url: /how-to/configure-oidc-with-token-exchange/
+ - text: OpenID Connect tutorials
+ url: /how-to/?query=openid-connect
+
+plugins:
+ - openid-connect
+
+entities:
+ - route
+ - service
+ - plugin
+
+products:
+ - gateway
+
+works_on:
+ - on-prem
+ - konnect
+
+tools:
+ - deck
+
+prereqs:
+ entities:
+ services:
+ - example-service
+ routes:
+ - example-route
+ inline:
+ - title: Set up Keycloak with two realms
+ include_content: prereqs/auth/oidc/keycloak-multi-idp
+ icon_url: /assets/icons/keycloak.svg
+
+tags:
+ - authentication
+ - openid-connect
+search_aliases:
+ - oidc
+ - multi-idp
+ - multiple identity providers
+
+tldr:
+ q: How do I configure {{site.base_gateway}} to accept tokens from multiple identity providers on the same route?
+ a: |
+ Configure the OpenID Connect plugin with `extra_jwks_uris` listing each IdP's JWKS endpoint and `issuers_allowed` listing each IdP's issuer URL.
+ Set `verify_claims` to `false` so that the `iss` claim is checked against `issuers_allowed` rather than requiring it to match `config.issuer`.
+ {{site.base_gateway}} validates incoming tokens against the matching JWKS and forwards them to the upstream without transformation.
+
+cleanup:
+ inline:
+ - title: Clean up Konnect environment
+ include_content: cleanup/platform/konnect
+ icon_url: /assets/icons/gateway.svg
+ - title: Destroy the {{site.base_gateway}} container
+ include_content: cleanup/products/gateway
+ icon_url: /assets/icons/gateway.svg
+
+automated_tests: false
+---
+
+## Generate salt token
+
+{% include how-tos/steps/deck-salt-token.md %}
+
+## Enable the OpenID Connect plugin for multiple IdPs
+
+Using the Keycloak configuration from the [prerequisites](#prerequisites), configure the OpenID Connect plugin on `example-route` to accept tokens from both realms:
+
+{% entity_examples %}
+entities:
+ plugins:
+ - name: openid-connect
+ route: example-route
+ config:
+ issuer: ${realm-a-issuer}
+ using_pseudo_issuer: true
+ jwks_endpoint: ${realm-a-jwks}
+ auth_methods:
+ - bearer
+ extra_jwks_uris:
+ - ${realm-b-jwks}
+ issuers_allowed:
+ - ${realm-a-issuer}
+ - ${realm-b-issuer}
+ verify_signature: true
+ verify_claims: false
+ cache_tokens_salt: ${salt-token}
+variables:
+ realm-a-issuer:
+ value: $REALM_A_ISSUER
+ realm-a-jwks:
+ value: $REALM_A_JWKS
+ realm-b-jwks:
+ value: $REALM_B_JWKS
+ realm-b-issuer:
+ value: $REALM_B_ISSUER
+{% endentity_examples %}
+
+Auth configuration:
+* `issuer`: The primary IdP URL, used for discovery and as the canonical issuer reference.
+* `using_pseudo_issuer`: Disables OIDC discovery from the `issuer` URL. Required here because {{site.base_gateway}} runs inside Docker and can't reach `localhost:8080` directly. The `issuer` value is still used to match the `iss` claim in tokens from `realm-a`.
+* `jwks_endpoint`: Explicit JWKS endpoint that {{site.base_gateway}} uses to fetch signing keys for `realm-a`. Uses the `keycloak` container name, which is reachable from {{site.base_gateway}} over the shared Docker network.
+* `extra_jwks_uris`: The JWKS endpoint for `realm-b`. The plugin checks the primary JWKS first, then falls back to this list.
+* `issuers_allowed`: Explicit allowlist of accepted issuers. Tokens whose `iss` claim doesn't match one of these values are rejected.
+* `verify_claims`: Set to `false` so that the `iss` claim is checked against `issuers_allowed` instead of requiring it to equal `config.issuer`. Without this, tokens from `realm-b` would fail claim verification.
+
+## Validate the flow
+
+### Token from realm-a
+
+Get a client credentials token from `realm-a`:
+
+```sh
+TOKEN_A=$(curl -s -X POST \
+ http://$KEYCLOAK_HOST:8080/realms/master/protocol/openid-connect/token \
+ -d "grant_type=client_credentials" \
+ -d "client_id=client-a" \
+ -d "client_secret=$DECK_CLIENT_A_SECRET" | jq -r .access_token) && echo $TOKEN_A
+```
+
+Send the token to {{site.base_gateway}}:
+
+{% validation request-check %}
+url: /anything
+method: GET
+status_code: 200
+display_headers: true
+headers:
+ - "Authorization: Bearer $TOKEN_A"
+{% endvalidation %}
+
+You should see a `200` response.
+When you decode the token in the forwarded `Authorization` header, the `iss` claim should be `http://localhost:8080/realms/master`.
+
+### Token from realm-b
+
+Get a client credentials token from `realm-b`:
+
+```sh
+TOKEN_B=$(curl -s -X POST \
+ http://$KEYCLOAK_HOST:8080/realms/realm-b/protocol/openid-connect/token \
+ -d "grant_type=client_credentials" \
+ -d "client_id=client-b" \
+ -d "client_secret=$DECK_CLIENT_B_SECRET" | jq -r .access_token) && echo $TOKEN_B
+```
+
+Send the token to {{site.base_gateway}}:
+
+{% validation request-check %}
+url: /anything
+method: GET
+status_code: 200
+display_headers: true
+headers:
+ - "Authorization: Bearer $TOKEN_B"
+{% endvalidation %}
+
+You should see a `200` response.
+When you decode the token in the forwarded `Authorization` header, the `iss` claim should be `http://localhost:8080/realms/realm-b`, confirming that {{site.base_gateway}} accepted the token from the second IdP and forwarded it unchanged.
diff --git a/app/_includes/plugins/oidc/diagrams/multi-idp-trusted-issuers.md b/app/_includes/plugins/oidc/diagrams/multi-idp-trusted-issuers.md
new file mode 100644
index 0000000000..2cf7bb8244
--- /dev/null
+++ b/app/_includes/plugins/oidc/diagrams/multi-idp-trusted-issuers.md
@@ -0,0 +1,29 @@
+
+{% mermaid %}
+sequenceDiagram
+ participant C as Client
+ participant K as API Gateway
with OIDC plugin
+ participant IdPA as IdP A
(primary issuer)
+ participant IdPB as IdP B
(via extra_jwks_uris)
+ participant U as Upstream
(backend service)
+
+ C->>K: Request with bearer token
+ activate K
+ K->>K: Extract iss claim from token
+ alt If iss matches IdP A
+ K->>IdPA: Fetch JWKS (if not cached)
+ IdPA-->>K: Public keys
+ else If iss matches IdP B
+ K->>IdPB: Fetch JWKS (if not cached)
+ IdPB-->>K: Public keys
+ end
+ K->>K: Verify signature
+ K->>K: Check iss against issuers_allowed
+ K->>U: Proxy request with original token
+ activate U
+ U-->>K: Response
+ deactivate U
+ K-->>C: Response
+ deactivate K
+{% endmermaid %}
+
\ No newline at end of file
diff --git a/app/_includes/prereqs/auth/oidc/keycloak-multi-idp.md b/app/_includes/prereqs/auth/oidc/keycloak-multi-idp.md
new file mode 100644
index 0000000000..8113c5323c
--- /dev/null
+++ b/app/_includes/prereqs/auth/oidc/keycloak-multi-idp.md
@@ -0,0 +1,130 @@
+This tutorial requires two identity providers (IdPs).
+If you don't have them, you can simulate two separate IdPs using two Keycloak realms.
+The steps will be similar with other standard identity providers.
+
+#### Install and run Keycloak
+
+1. Install [Keycloak](https://www.keycloak.org/guides) (version 26 or later) on your platform.
+
+ For example, you can use the Keycloak Docker image. The following command attaches Keycloak to the same network as {{site.base_gateway}} so that the OIDC plugin can reach it:
+
+ ```sh
+ docker run -p 127.0.0.1:8080:8080 \
+ --name keycloak \
+ --network kong-quickstart-net \
+ -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
+ -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
+ -e KC_HOSTNAME=http://localhost:8080 \
+ quay.io/keycloak/keycloak start-dev
+ ```
+
+ The parameter `KC_HOSTNAME=http://localhost:8080` ensures Keycloak always uses `localhost:8080` as its token issuer regardless of which URL it's accessed through.
+ This is required because {{site.base_gateway}} runs inside Docker and accesses Keycloak via the container name `keycloak:8080`, but the `iss` claim in issued tokens must use `localhost:8080` for the plugin to recognize them.
+
+1. Export your environment variables. For example, using Docker and the `master` and `realm-b` realms:
+
+ ```sh
+ export DECK_REALM_A_ISSUER='http://localhost:8080/realms/master'
+ export DECK_REALM_B_ISSUER='http://localhost:8080/realms/realm-b'
+ export DECK_REALM_A_JWKS='http://keycloak:8080/realms/master/protocol/openid-connect/certs'
+ export DECK_REALM_B_JWKS='http://keycloak:8080/realms/realm-b/protocol/openid-connect/certs'
+ export KEYCLOAK_HOST='localhost'
+ ```
+
+ Because we're using Docker for this demo, we must configure a few networking parameters:
+ * `DECK_REALM_A_ISSUER` and `DECK_REALM_B_ISSUER` use `localhost` because that's how you access Keycloak from your machine.
+ * `DECK_REALM_A_JWKS` and `DECK_REALM_B_JWKS` use the container name `keycloak` because {{site.base_gateway}} runs inside Docker and reaches Keycloak over the shared `kong-quickstart-net` network.
+
+ In your own setup, especially running outside of a container, you may not need `DECK_REALM_A_JWKS` and `DECK_REALM_B_JWKS`.
+
+1. Open the Keycloak admin console.
+
+ The default URL is `http://localhost:8080/admin/master/console/`.
+
+1. Log in with the credentials you defined when you launched Keycloak.
+For this example, the credentials are username `admin` and password `admin`.
+
+#### Configure the first IdP
+
+The `master` realm acts as the first identity provider.
+Create a client for `realm-a`:
+
+1. In the sidebar, open **Clients**, then click **Create client**.
+1. Configure the client:
+
+
+{% table %}
+columns:
+ - title: Section
+ key: section
+ - title: Settings
+ key: settings
+rows:
+ - section: "**General settings**"
+ settings: |
+ * Client type: **OpenID Connect**
+ * Client ID: `client-a`
+ - section: "**Capability config**"
+ settings: |
+ * Toggle **Client authentication** to **on**
+ * Select the **Service accounts roles** checkbox.
+{% endtable %}
+
+
+Find the credentials for `client-a`:
+
+1. In the sidebar, open **Clients**, and select `client-a`.
+1. Click the **Credentials** tab.
+1. Set **Client Authenticator** to **Client ID and Secret**.
+1. Copy the **Client Secret**.
+1. Export the client secret to an environment variable:
+
+ ```sh
+ export DECK_CLIENT_A_SECRET='YOUR-CLIENT-SECRET'
+ ```
+
+#### Configure the second IdP
+
+Create a second Keycloak realm to simulate a second identity provider:
+
+1. In the sidebar click **Manage realms**.
+1. Click **Create realm**.
+1. Set **Realm name** to `realm-b`.
+1. Click **Create**.
+
+Create a client for `realm-b`:
+
+1. Make sure you're in the `realm-b` realm (check the top-left dropdown).
+1. In the sidebar, open **Clients**, then click **Create client**.
+1. Configure the client:
+
+
+{% table %}
+columns:
+ - title: Section
+ key: section
+ - title: Settings
+ key: settings
+rows:
+ - section: "**General settings**"
+ settings: |
+ * Client type: **OpenID Connect**
+ * Client ID: `client-b`
+ - section: "**Capability config**"
+ settings: |
+ * Toggle **Client authentication** to **on**
+ * Select the **Service accounts roles** checkbox.
+{% endtable %}
+
+
+Find the credentials for `client-b`:
+
+1. In the sidebar, open **Clients**, and select `client-b`.
+1. Open the **Credentials** tab.
+1. Set **Client Authenticator** to **Client ID and Secret**.
+1. Copy the **Client Secret**.
+1. Export the client secret to an environment variable:
+
+ ```sh
+ export DECK_CLIENT_B_SECRET='YOUR-CLIENT-SECRET'
+ ```
diff --git a/app/_kong_plugins/openid-connect/examples/extra-jwks.yaml b/app/_kong_plugins/openid-connect/examples/extra-jwks.yaml
index 72afdc77e3..a131f2a747 100644
--- a/app/_kong_plugins/openid-connect/examples/extra-jwks.yaml
+++ b/app/_kong_plugins/openid-connect/examples/extra-jwks.yaml
@@ -10,9 +10,11 @@ extended_description: |
This example shows how to configure two different `extra_jwks_uris` to support token validation for two different IdPs.
+ For a complete example with Keycloak as the IdP, see the tutorial for [configuring OpenID Connect with multiple IdPs using a trusted issuer registry](/how-to/configure-oidc-with-multi-idp/).
+
{:.info}
> **Note**: `extra_jwks_uris` adds multiple discovery endpoints, but the plugin will still look at the `jwks_uri` returned by the internal discovery mechanism first, introducing potential latency.
- If you want to override the default discovery JWKS endpoint instead of providing multiple fallback options, see the [Use a custom JWKS endpoint for discovery example](/plugins/openid-connect/examples/override-jwks-endpoint/).
+ If you want to override the default discovery JWKS endpoint instead of providing multiple fallback options, see the [Use a custom JWKS endpoint for discovery example](/plugins/openid-connect/examples/override-jwks-endpoint/).
weight: 698
requirements:
diff --git a/app/_kong_plugins/openid-connect/index.md b/app/_kong_plugins/openid-connect/index.md
index ccf287b0ff..fbfae4730e 100644
--- a/app/_kong_plugins/openid-connect/index.md
+++ b/app/_kong_plugins/openid-connect/index.md
@@ -87,13 +87,15 @@ faqs:
a: |
Yes, but since the OIDC plugin only accepts one issuer URL, this requires some extra configuration.
- You can verify tokens issued by multiple IdP using the [`extra_jwks_uris`](/plugins/openid-connect/reference/#schema--config-extra-jwks-uris) configuration option, with the following considerations:
+ You can verify tokens issued by multiple IdP using the [`extra_jwks_uris`](/plugins/openid-connect/reference/#schema--config-extra-jwks-uris) configuration option with the following considerations:
* Since the plugin only accepts a single issuer, any `iss` claim verification will fail for tokens that come from a different IdP than the one that was used in the issuer configuration option. Add all issuers as they appear in the `iss` claims of your tokens to the [`config.issuers_allowed`](/plugins/openid-connect/reference/#schema--config-issuers-allowed) setting.
* If you make any changes to the `extra_jwks_uris` value, you have to clear the second level DB cache for the change to become effective.
See [Delete a Discovery Cache Object](/plugins/openid-connect/api/#/operations/deleteDiscoveryCache).
- See the [Extra JWKs](/plugins/openid-connect/examples/extra-jwks/) configuration example for more detail.
+ You can also use [token exchange](#token-exchange), which allows exchanging an existing security token for a new one.
+
+ See the [OIDC multi-IdP](/plugins/openid-connect/multi-idp/) reference for more detail.
- q: How do I enable the Proof Key for Code Exchange (PKCE) extension to the authorization code flow in the OIDC plugin?
a: |
The OIDC plugin supports PKCE out of the box, so you don't need to configure anything.
@@ -562,6 +564,22 @@ To enable DPoP for OpenID Connect:
See the [DPoP configuration example](/plugins/openid-connect/examples/dpop/) for more detail.
+## Multi-IdP support
+
+If your APIs serve clients that authenticate with different identity providers, the OIDC plugin can validate tokens from multiple issuers at the gateway layer, so backends don't need per-IdP logic.
+
+You can implement this in one of the following ways:
+
+* **Trusted issuers registry**: Configure the OIDC plugin with a list of trusted issuers and their JWKS endpoints using [`config.issuers_allowed`](/plugins/openid-connect/reference/#schema--config-issuers-allowed) and [`config.extra_jwks_uris`](/plugins/openid-connect/reference/#schema--config-extra-jwks-uris).
+{{site.base_gateway}} validates incoming tokens against the appropriate public keys and forwards them to the backend as-is.
+This works best when token formats are consistent across IdPs.
+
+* **Token exchange** {% new_in 3.14 %}: Configure the OIDC plugin to swap incoming tokens for a canonical token from one trusted issuer using [`config.token_exchange`](/plugins/openid-connect/reference/#schema--config-token-exchange).
+The backend always receives tokens from a single issuer regardless of which IdP the client used.
+This works best when backends must trust one issuer, or when you need to normalize scopes and claims across IdPs.
+
+For a detailed comparison, configuration parameters, and examples, see [Multi-IdP token validation at the gateway layer](/plugins/openid-connect/multi-idp/).
+
## Token exchange {% new_in 3.14 %}
The [OAuth 2.0 Token Exchange](https://oauth.net/2/token-exchange/) (RFC 8693) is an extension to the OAuth 2.0 framework that allows exchanging an existing security token for a new one.
diff --git a/app/gateway/plugins/oidc/multi-idp.md b/app/gateway/plugins/oidc/multi-idp.md
new file mode 100644
index 0000000000..68bab59dc6
--- /dev/null
+++ b/app/gateway/plugins/oidc/multi-idp.md
@@ -0,0 +1,197 @@
+---
+title: Multi-IdP token validation with OpenID Connect
+
+description: "Configure the OpenID Connect plugin to validate tokens from multiple identity providers using a trusted issuer registry or token exchange."
+content_type: reference
+layout: reference
+permalink: /plugins/openid-connect/multi-idp/
+
+products:
+ - gateway
+
+breadcrumbs:
+ - /plugins/
+ - /plugins/openid-connect/
+
+
+works_on:
+ - on-prem
+ - konnect
+
+related_resources:
+ - text: OpenID Connect in {{site.base_gateway}}
+ url: /gateway/openid-connect/
+ - text: OpenID Connect plugin reference
+ url: /plugins/openid-connect/
+ - text: "How-to: Configure OpenID Connect with multiple IdPs"
+ url: /how-to/configure-oidc-with-multi-idp/
+ - text: "How-to: Configure OpenID Connect with token exchange using Keycloak"
+ url: /how-to/configure-oidc-with-token-exchange/
+---
+
+If your APIs serve clients from multiple identity providers (IdPs), (for example, employees using Okta, B2B partners using Azure AD, or legacy systems on an in-house IdP), the [OpenID Connect (OIDC) plugin](/plugins/openid-connect/) can act as a federated authentication broker at the gateway layer. In this setup, backends don't need per-IdP validation logic.
+{{site.base_gateway}} centralizes auth policy and forwards only the verified identity context upstream.
+
+The OIDC plugin supports two approaches for multi-IdP authentication, both using [JWT access token (bearer) auth](/plugins/openid-connect/#jwt-access-token-authentication-flow).
+Clients authenticate against their respective IdPs and present the resulting bearer token to {{site.base_gateway}}:
+
+{% table %}
+columns:
+ - title: ""
+ key: label
+ - title: "[Trusted issuers registry](#option-1-trusted-issuers-registry)"
+ key: option1
+ - title: "[Token exchange](#option-2-token-exchange)"
+ key: option2
+rows:
+ - label: "How it works"
+ option1: "Validates tokens from multiple issuers against their JWKS endpoints. Backends receive the original, unmodified token."
+ option2: "Exchanges incoming tokens for a canonical token from one trusted target issuer. Backends only ever see tokens from that issuer."
+ - label: "When to use"
+ option1: "Token formats are consistent across IdPs. Backends can accept tokens from different issuers."
+ option2: "Backends must trust a single issuer. Token formats differ across IdPs. You need downscoping, normalization, or cross-domain federation."
+ - label: "IdP requirements"
+ option1: "No special grant needed."
+ option2: "IdPs must support [RFC 8693](https://www.rfc-editor.org/rfc/rfc8693) token exchange."
+ - label: "Min version"
+ option1: "Any"
+ option2: "3.14"
+ - label: "Key config parameters"
+ option1: "`config.issuers_allowed`, `config.extra_jwks_uris`"
+ option2: "`config.token_exchange.subject_token_issuers`"
+{% endtable %}
+
+## Option 1: Trusted issuers registry
+
+In this approach, {{site.base_gateway}} acts as a federated authentication broker maintaining a registry of trusted issuers and their public key endpoints.
+The plugin inspects the `iss` claim of an incoming bearer token, looks up the matching JWKS endpoint from the configured list, validates the token signature and standard claims, then forwards the verified request upstream.
+No token transformation occurs.
+
+{% include_cached /plugins/oidc/diagrams/multi-idp-trusted-issuers.md %}
+
+Configure the following OIDC plugin settings:
+
+* [`config.issuers_allowed`](/plugins/openid-connect/reference/#schema--config-issuers-allowed): Allowlist of issuer URLs the plugin will accept.
+Add every IdP's issuer URL here, exactly as it appears in the `iss` claim of their tokens.
+* [`config.extra_jwks_uris`](/plugins/openid-connect/reference/#schema--config-extra-jwks-uris): Additional JWKS endpoints for each IdP beyond the primary `config.issuer`.
+The plugin checks the primary discovery JWKS first, then falls back to these.
+
+This approach works best when tokens issued by each IdP follow the same claim naming conventions.
+
+{:.info}
+> The plugin uses `config.issuer` for discovery and to identify the primary issuer.
+> Tokens from other IdPs will fail `iss` claim verification unless you set [`config.verify_claims`](/plugins/openid-connect/reference/#schema--config-verify-claims) to `false` and control allowed issuers via `config.issuers_allowed` instead.
+
+If you update `config.extra_jwks_uris` after the plugin is already configured, [clear the discovery cache](/plugins/openid-connect/api/#/operations/deleteDiscoveryCache) for the change to take effect.
+
+### Configuration example
+
+The following example configures the OIDC plugin to accept tokens from two identity providers.
+The first IdP is the primary `config.issuer`, while the second is added via `config.extra_jwks_uris`:
+
+```yaml
+config:
+ issuer: https://idp-a.example.com
+ auth_methods:
+ - bearer
+ extra_jwks_uris:
+ - https://idp-b.example.com/oauth2/v1/keys
+ issuers_allowed:
+ - https://idp-a.example.com
+ - https://idp-b.example.com
+ verify_signature: true
+ verify_claims: false
+```
+
+In this example, a client authenticated with `idp-a` presents a bearer token.
+{{site.base_gateway}} validates it against `idp-a`'s JWKS and checks the issuer against `config.issuers_allowed`.
+The same flow applies to a client from `idp-b`.
+Both reach the upstream service without any token transformation.
+
+For more detail and a complete walkthrough:
+* [Plugin example: Token validation for multiple IdPs](/plugins/openid-connect/examples/extra-jwks/)
+* [How-to: Configure OpenID Connect with multiple IdPs](/how-to/configure-oidc-with-multi-idp/)
+
+## Option 2: Token exchange {% new_in 3.14 %}
+
+Token exchange is a standard, protocol-driven way to swap an incoming security token for a new one.
+Using JWT access token authentication, the plugin validates the incoming bearer token (the "subject token"), then uses its own client credentials to exchange it with the target issuer per [RFC 8693](https://www.rfc-editor.org/rfc/rfc8693).
+The upstream service receives the exchanged token from the target issuer, regardless of which IdP the client originally authenticated with.
+
+{% include_cached /plugins/oidc/diagrams/token-exchange.md %}
+
+This approach also unlocks use cases beyond multi-IdP:
+
+* Downscoping: Exchange a high-privilege token for one with fewer scopes before forwarding to a less-trusted upstream.
+* Cross-domain federation: Exchange tokens when clients use one IdP but your APIs are protected by another.
+* Token translation: Convert tokens with different claim structures into a consistent internal format that your microservices understand.
+* On-behalf flows: Set up delegation and impersonation, where one client acts on behalf of another.
+
+Key configuration parameters:
+
+* [`config.token_exchange.subject_token_issuers`](/plugins/openid-connect/reference/#schema--config-token-exchange-subject-token-issuers): Explicit list of trusted input issuers.
+The plugin only exchanges tokens whose `iss` claim matches an entry here.
+* [`config.token_exchange.conditions`](/plugins/openid-connect/reference/#schema--config-token-exchange-conditions): Optional per-issuer rules that control when to trigger the exchange.
+If the subject token issuer and the target issuer (the one configured in `config.issuer`) are different, exchange always triggers.
+If they match, conditions determine whether to exchange.
+* [`config.token_exchange.request`](/plugins/openid-connect/reference/#schema--config-token-exchange-request): The scopes and audience to request in the exchanged token.
+
+With token exchange, trust is strictly enforced on both sides.
+{{site.base_gateway}} only exchanges tokens whose issuer is explicitly listed in `subject_token_issuers`.
+Each IdP in the list must also authorize {{site.base_gateway}} as a trusted client eligible for the token exchange grant.
+
+### Configuration example
+
+The following example configures {{site.base_gateway}} to act as `kong-client` at the target issuer (`idp-a`) and exchange tokens issued by `idp-b`:
+
+```yaml
+config:
+ issuer: https://idp-a.example.com
+ client_id:
+ - kong-client
+ client_secret:
+ - ${kong-client-secret}
+ client_auth:
+ - client_secret_post
+ auth_methods:
+ - bearer
+ token_exchange:
+ subject_token_issuers:
+ - issuer: https://idp-b.example.com
+```
+
+When a client from `idp-b` presents a bearer token, {{site.base_gateway}} validates it, then exchanges it with `idp-a` to produce a token the upstream service trusts.
+Tokens already issued by `idp-a` are validated as-is unless conditions require an exchange.
+
+For more detail, see:
+* [Plugin example: Token exchange for cross-domain security](/plugins/openid-connect/examples/token-exchange-cross-domain/)
+* [Plugin example: Token transformation](/plugins/openid-connect/examples/token-exchange-transformation/)
+* [How-to: Configure OpenID Connect with token exchange using Keycloak](/how-to/configure-oidc-with-token-exchange/)
+* [Token exchange reference](/plugins/openid-connect/#token-exchange)
+
+## Troubleshooting
+
+The following errors can appear in the {{site.base_gateway}} error log whether you're using one issuer or multiple.
+The fix differs depending on which approach you're using.
+For general debugging steps, see [Debugging the OIDC plugin](/plugins/openid-connect/#debugging-the-oidc-plugin).
+
+{% table %}
+columns:
+ - title: Error
+ key: error
+ - title: Log message
+ key: log
+ - title: Likely cause
+ key: cause
+rows:
+ - error: Expired token
+ log: "`invalid exp claim () was specified for access token`"
+ cause: |
+ The token's `exp` claim is in the past. Get a new token from the IdP.
+ - error: Signature verification failure
+ log: "`invalid signature (pkey:verify: ...)`"
+ cause: |
+ The signing key for the token's issuer isn't available to the plugin.
+ * If using a trusted issuer registry, check that the issuer's JWKS endpoint is listed in `config.extra_jwks_uris` and that the [discovery cache has been cleared](/plugins/openid-connect/api/#/operations/deleteDiscoveryCache) after any config change.
+ * If using token exchange, confirm that `config.token_exchange.subject_token_issuers` includes the token's `iss` value.
+{% endtable %}