OAuth 2.0 Token Exchange Authentication
The OAuth 2.0 Token Exchange middleware lets Hub API Gateway exchange an incoming subject token for a new access token scoped to a downstream service, following RFC 8693. It also supports the RFC 7523 JWT Bearer grant, and Microsoft Entra ID's On-Behalf-Of flow, which is the same grant with Microsoft-specific parameters added.
Every application brings its subject token to Hub API Gateway using one of the following sources:
- A header (with a scheme if the token is provided using the
Authorizationheader, the default), - A query parameter,
- A cookie.
Hub API Gateway then calls the authorization server to exchange the subject token for a new access token, and forwards the request upstream with the exchanged token in the Authorization header.
RFC 8693 Token Exchange
In this mode, Hub API Gateway exchanges the subject token by sending the RFC 8693
urn:ietf:params:oauth:grant-type:token-exchange grant to the authorization server, as described in the diagram below.
To exchange a JWT subject token read from the Authorization header for a new access token, apply the following configuration after adjusting it to your needs:
- Middleware RFC 8693 Token Exchange
- Kubernetes Secret
- IngressRoute
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oauth-token-exchange
namespace: apps
spec:
plugin:
oAuthTokenExchange:
method: rfc8693
url: https://tenant.auth0.com/oauth/token
clientID: "urn:k8s:secret:oauth-client:client_id"
clientSecret: "urn:k8s:secret:oauth-client:client_secret"
rfc8693:
subjectTokenType: "urn:ietf:params:oauth:token-type:jwt"
requestedTokenType: "urn:ietf:params:oauth:token-type:access_token"
audience: https://api.example.com
apiVersion: v1
kind: Secret
metadata:
name: oauth-client
namespace: apps
stringData:
client_id: my-oauth-client-id
client_secret: my-oauth-client-secret
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: secure-applications-apigateway-oauth-token-exchange
namespace: apps
spec:
entryPoints:
- websecure
routes:
- match: Path(`/my-app`)
kind: Rule
services:
- name: whoami
port: 80
middlewares:
- name: oauth-token-exchange
Keycloak requires rfc8693.subjectTokenType: "urn:ietf:params:oauth:token-type:access_token". Use this value instead of jwt when the authorization server is Keycloak.
Advanced options are described in the reference page.
For example, you can find how to add a cache layer using a Redis store so Hub API Gateway does not exchange the same subject token on every request.
Pinning the client authentication method
By default, Hub API Gateway auto-detects whether the authorization server expects client credentials in an HTTP Basic header or in the request body, and reuses that detection for the exchange. If the authorization server rejects HTTP Basic, this detection adds one extra request to the authorization server on every token exchange.
Set rfc8693.clientAuthMethod to skip detection and send credentials the right way on the
first try:
in_header— send credentials using HTTP Basic authentication.in_params— send credentials in the request body.
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oauth-token-exchange
namespace: apps
spec:
plugin:
oAuthTokenExchange:
method: rfc8693
url: https://tenant.auth0.com/oauth/token
clientID: "urn:k8s:secret:oauth-client:client_id"
clientSecret: "urn:k8s:secret:oauth-client:client_secret"
rfc8693:
subjectTokenType: "urn:ietf:params:oauth:token-type:jwt"
requestedTokenType: "urn:ietf:params:oauth:token-type:access_token"
audience: https://api.example.com
clientAuthMethod: in_params
Auto-detection (the default) tries HTTP Basic first and falls back to the request body if
the authorization server rejects it. Pinning rfc8693.clientAuthMethod removes that
fallback along with the extra request. If you pin the wrong method for your authorization
server, every exchange fails instead of falling back. Confirm which style your
authorization server expects before pinning it in production.
RFC 7523 JWT Bearer Grant
In this mode, Hub API Gateway exchanges the subject token by sending the RFC 7523
urn:ietf:params:oauth:grant-type:jwt-bearer grant to the authorization server, with the subject token sent as the assertion.
- Middleware RFC 7523 JWT Bearer Grant
- Kubernetes Secret
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oauth-token-exchange-rfc7523
namespace: apps
spec:
plugin:
oAuthTokenExchange:
method: rfc7523
url: https://resource-as.example.com/token
clientID: "urn:k8s:secret:oauth-client:client_id"
clientSecret: "urn:k8s:secret:oauth-client:client_secret"
rfc7523:
scopes:
- profile
apiVersion: v1
kind: Secret
metadata:
name: oauth-client
namespace: apps
stringData:
client_id: my-oauth-client-id
client_secret: my-oauth-client-secret
method: entraID sends this same grant, with the Microsoft-specific requested_token_use=on_behalf_of parameter added on every request.
method: rfc7523 omits that parameter, which is what lets it work against a generic authorization server such as Keycloak instead of only Microsoft Entra ID.
Microsoft Entra ID On-Behalf-Of
Microsoft Entra ID diverges from RFC 8693 with its own On-Behalf-Of grant.
Set method: entraID to use it. Entra ID requires client authentication, either with a client secret or with a client certificate.
Using a client secret
- Middleware Entra ID with client secret
- Kubernetes Secret
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oauth-token-exchange-entraid
namespace: apps
spec:
plugin:
oAuthTokenExchange:
method: entraID
url: https://login.microsoftonline.com/YOUR-TENANT-ID/oauth2/v2.0/token
clientID: "urn:k8s:secret:oauth-client:client_id"
clientSecret: "urn:k8s:secret:oauth-client:client_secret"
entraID:
scopes:
- api://downstream-app/.default
apiVersion: v1
kind: Secret
metadata:
name: oauth-client
namespace: apps
stringData:
client_id: my-entraid-client-id
client_secret: my-entraid-client-secret
Using a client certificate
Instead of a client secret, Entra ID can authenticate the client with a signed JWT assertion built from an RSA certificate and private key:
- Middleware Entra ID with client certificate
- Kubernetes Secret
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oauth-token-exchange-entraid-cert
namespace: apps
spec:
plugin:
oAuthTokenExchange:
method: entraID
url: https://login.microsoftonline.com/YOUR-TENANT-ID/oauth2/v2.0/token
clientID: "urn:k8s:secret:oauth-client:client_id"
entraID:
scopes:
- api://downstream-app/.default
clientCertificate:
certificate: "urn:k8s:secret:oauth-client-cert:certificate"
privateKey: "urn:k8s:secret:oauth-client-cert:privateKey"
apiVersion: v1
kind: Secret
metadata:
name: oauth-client-cert
namespace: apps
stringData:
certificate: |-
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
privateKey: |-
-----BEGIN PRIVATE KEY-----
...
-----END PRIVATE KEY-----
Microsoft Entra ID only accepts RSA certificate credentials signed with PS256. PKCS1 and PKCS8 RSA private keys are supported; other key types are rejected.
Chaining for ID-JAG (Cross App Access)
Two oAuthTokenExchange middlewares can chain on one route to let one application access another application's resources on a user's behalf.
Neither application implements the exchange itself, and the user does not sign in again.
This pattern is known as Cross App Access: the caller's identity provider issues an assertion, and the resource's authorization server redeems it for a downstream-scoped access token.
The assertion format is defined by ID-JAG, the IETF OAuth working group's Identity Assertion Authorization Grant draft.
Okta's Cross App Access documentation describes the same pattern from the vendor side.
Set up the chain with three middlewares, applied in this order.
Step 1: forward the ID token from the OIDC session
Add forwardIDTokenHeader to your OIDC middleware so the raw ID token becomes available in a header, ready to feed into the first token exchange:
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oidc-forward-id-token
namespace: apps
spec:
plugin:
oidc:
issuer: "https://idp.example.com/realms/myrealm"
redirectUrl: "/callback"
clientID: "urn:k8s:secret:oidc-client:client_id"
clientSecret: "urn:k8s:secret:oidc-client:client_secret"
forwardIDTokenHeader: X-Forwarded-Id-Token
Step 2: exchange the ID token for an ID-JAG assertion
Read the ID token from that same header, and request an ID-JAG from the caller's identity provider:
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: step-2-request-assertion
namespace: apps
spec:
plugin:
oAuthTokenExchange:
tokenSource:
header: X-Forwarded-Id-Token
method: rfc8693
url: https://idp.example.com/token
clientID: "urn:k8s:secret:chain:clienta_id"
clientSecret: "urn:k8s:secret:chain:clienta_secret"
rfc8693:
subjectTokenType: "urn:ietf:params:oauth:token-type:id_token"
requestedTokenType: "urn:ietf:params:oauth:token-type:id-jag"
audience: https://resource-as.example.com
resource: https://api.example.com
scopes:
- profile
Step 3: redeem the assertion at the resource's authorization server
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: step-3-redeem-assertion
namespace: apps
spec:
plugin:
oAuthTokenExchange:
method: rfc7523
url: https://resource-as.example.com/token
clientID: "urn:k8s:secret:chain:clientb_id"
clientSecret: "urn:k8s:secret:chain:clientb_secret"
An authorization server can require that the assertion carries the scopes and that the redemption request carries no different scopes.
Okta, for example, rejects an assertion that carries no scope when it is redeemed (invalid_grant: The id-jag must include at least one scope),
and rejects a redemption whose scope parameter differs from the assertion's scopes (invalid_request: The scope parameter must exactly match the scopes in the id-jag).
Set rfc8693.scopes on step 2 and leave scopes out of step 3. Step 2 is the request that issues the assertion, and the scopes are encoded into it there.
Wire the Middlewares onto the Route, in Order
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: idjag-chain
namespace: apps
spec:
entryPoints:
- websecure
routes:
- match: Host(`chain.example.com`)
kind: Rule
services:
- name: upstream
port: 80
middlewares:
- name: oidc-forward-id-token
- name: step-2-request-assertion
- name: step-3-redeem-assertion
Each oAuthTokenExchange instance reads its subject token from tokenSource and writes its result to the Authorization header. When tokenSource names a different header, the instance also removes that header from the request.
List the three middlewares in the exact order shown above.
Getting the order wrong sends the wrong token to the wrong server.
Confirm the header name in forwardIDTokenHeader (step 1) matches the header name in tokenSource.header (step 2) exactly.
Hub does not remove that header from an incoming request by default, so a caller can set it directly.
If step 1 does not run first on the route, or its header name does not match, the chain processes whatever value the caller supplied instead of a token from an authenticated session.
On any route where step 1 does not run before it, remove this header from incoming requests before they reach the chain, so a caller cannot supply their own value.
Do not enable store on step 2.
The assertion it returns can only be redeemed once, and caching it breaks the chain from the second request onward.
Enabling store on step 3 doesn't help in this chain. Its cache is keyed by the incoming assertion, and step 2 issues a new assertion on every request, so the cache never returns a stored token.
Confirm the token-type pairing before applying the configuration.
rfc8693.requestedTokenType and rfc8693.subjectTokenType must be a pairing your identity provider actually supports for this flow.
Check both values against your provider's documentation.
An unsupported pairing is rejected by the authorization server when the exchange runs.
The redeeming side of this chain works against any authorization server that implements the grant. Support for the requesting side, issuing the assertion, varies. Okta implements it as Cross App Access, and Descope as Cross-App Access. Check your identity provider's documentation to confirm it can issue an ID-JAG assertion and how to enable the redeeming side. For Keycloak's support for this grant, see Keycloak's identity assertion JWT authorization grant guide. For step-by-step setups, see Cross App Access with Okta and Cross App Access with Descope.
Related Content
- See the full options in the dedicated section.
- See a complete Cross App Access setup with Okta or Descope.
- See how to secure your API using OAuth2 Client Credentials.
- See how to secure your API using OAuth2 Token Introspection.
