OAuth 2.0 Token Exchange Authentication
This feature is currently in early access.
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"
Okta refuses a redemption request that carries a scope parameter at all (invalid_request: The id-jag request must not include a 'scope' parameter),
and separately refuses an assertion carrying no scopes at all (invalid_grant: The id-jag must include at least one scope).
Set rfc8693.scopes on step 2 instead. That's the request that mints the assertion, and the scopes get encoded into it there.
Step 3 then redeems the assertion without sending scopes again.
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 a header and overwrites that same header with its result.
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.
You can enable store on step 3. It caches the resulting downstream access token, which behaves like any other exchanged token and can be reused until it expires.
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 before applying the configuration.
An unsupported pairing is rejected by the authorization server when the exchange runs.
The redeeming side of this chain works against any authorization server implementing the grant, including Keycloak. Support for the requesting side, minting the assertion, varies. Okta implements it as Cross App Access, and Keycloak does not yet issue one. Check your identity provider's documentation to confirm it can issue an ID-JAG assertion.
Related Content
- See the full options in the dedicated section.
- See how to secure your API using OAuth2 Client Credentials.
- See how to secure your API using OAuth2 Token Introspection.
