Configure Cross App Access with Descope
Cross App Access (XAA) lets an AI agent call an application on a user's behalf without a second sign-in. In this guide, Traefik Hub API Gateway takes the caller's Descope session token, exchanges it at Descope for an identity assertion (ID-JAG), redeems the assertion at the resource's authorization server, and forwards the resulting access token to your upstream service. The gateway holds the client secrets for both exchanges and issues no token itself.
Descope needs two projects for this flow. Project 1 is the identity provider that issues the assertion. Project 2 is the resource authorization server that redeems it. Descope calls this feature Cross-App Access. For the generic chain and its sequence diagram, see Chaining for ID-JAG (Cross App Access). This page covers what to create in each Descope project and what to configure on the gateway.
Prerequisites
- Traefik Hub API Gateway v3.21 or later, with a valid license, running on Kubernetes.
- Two Descope projects, and access to both. The values below that read
<project 1 ID>and<project 2 ID>are the project identifiers from each project's settings. - An upstream service to protect. This guide uses an upstream service that echoes request headers, so you can see what the gateway forwards.
Plan the Two Projects
A Descope Resource of type api describes another party's server and is where you name the target authorization server.
A Resource of type mcp describes a server that the same project protects and doesn't accept the target settings.
The sending side and the receiving side are therefore always different projects, even when you own both.
Create the receiving side first. The projects reference each other, so project 2's tenant and inbound app must exist before project 1's Resource and client mapping.
Step 1: Create the Receiving Side in Project 2
Project 2 trusts project 1 by two values. Project 1's issuer is https://api.descope.com/v1/apps/<project 1 ID>, and its JWKS URL is https://api.descope.com/<project 1 ID>/.well-known/jwks.json.
For details, see Descope's guide to validating ID-JAGs.
- Create a tenant. Copy its tenant identifier.
- Open the tenant's SSO settings, turn on Cross-App Access, and add project 1's issuer and JWKS URL as a trusted issuer.
- Turn on just-in-time provisioning for the tenant's Cross-App Access. Project 2 then creates a user for each caller on first use. Without it, the second exchange succeeds only for users that already exist in the tenant.
- Create an inbound app that redeems the assertion, and add the scope
idjag.read. - Copy the app's client ID and secret. Descope shows the secret once.
Project 2 now trusts assertions from project 1 and has a client that can redeem them.
Step 2: Create the Sending Side in Project 1
For details, see Descope's guide to issuing ID-JAGs.
- Create an inbound app for the agent, and add the scope
idjag.read. Copy its client ID and secret. Descope shows the secret once. - Create a Resource of type
apiwhose identifier is the address of the server you call. It can point at an MCP server. Turn on Cross App Access for the Resource. Set its target authorization server tohttps://api.descope.com/v1/apps/<project 2 ID>, which becomes the assertion'saud. Set its target tenant to the tenant identifier from step 1, which becomes the assertion'saud_tenantclaim. If you leave the target tenant empty, the second exchange fails. Descope then sends the user's own tenant from project 1, or noaud_tenantwhen the user has none. Define the scopeidjag.readon the Resource. - Create a token exchange policy that permits the exchange for that Resource and grants the scope
idjag.read. - On the agent's app, map the Resource from step 2 to the client ID of the inbound app from step 1. The mapping is keyed by the Resource's URI, so a different spelling, such as a trailing slash, doesn't match.
Descope copies that client ID into the assertion's
client_idclaim, and project 2 compares it with the client that authenticates the second exchange. Without the mapping, the first exchange fails with errorE063314. - Create a user in project 1. The user is the person that the assertion names.
Set the target authorization server and the target tenant when you create the Resource. Deleting and recreating a Resource gives it a new identifier, so a policy that points at the old one is not removed, but it no longer matches and raises no error.
Step 3: Store the Client Credentials in a Kubernetes Secret
Reference the credentials with urn:k8s:secret:<name>:<key> instead of writing them into the middleware. Replace the placeholders with the client IDs and secrets from step 1 and step 2:
kubectl create secret generic descope-clients --namespace apps \
--from-literal=agentID=<project-1-app-client-id> \
--from-literal=agentSecret=<project-1-app-secret> \
--from-literal=resourceID=<project-2-app-client-id> \
--from-literal=resourceSecret=<project-2-app-secret>
Step 4: Configure the Middlewares and the Route
oAuthTokenExchange middlewares run in order on the route. Unlike a setup where one identity provider holds both ends, the two exchanges authenticate as different clients.
Request the Assertion
This middleware sends the caller's session token to project 1 and receives the assertion:
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: descope-xaa-request-assertion
namespace: apps
spec:
plugin:
oAuthTokenExchange:
method: rfc8693
url: https://api.descope.com/oauth2/v1/apps/token
clientID: urn:k8s:secret:descope-clients:agentID
clientSecret: urn:k8s:secret:descope-clients:agentSecret
tokenSource:
header: X-Subject-Token
rfc8693:
subjectTokenType: urn:ietf:params:oauth:token-type:id_token
requestedTokenType: urn:ietf:params:oauth:token-type:id-jag
audience: https://api.descope.com/v1/apps/<project 2 ID>
resource: https://api.example.com/payments
scopes:
- idjag.read
- Authenticate as project 1's app. Both exchanges go to the same host, and the client that authenticates decides the project.
- Set
rfc8693.subjectTokenTypeto the type of token the caller sends. This guide sends a Descope session token, which is an ID token. Descope accepts an ID token or a SAML assertion here and answers a refresh token with400. - Set
rfc8693.resourceto the URI of the Resource from step 2. Descope resolves the target from it, and the value must match exactly, including the port when the URI has one. - Set
rfc8693.scopeshere. The scopes you request must be on the Resource and granted by the token exchange policy.
Redeem the Assertion
This middleware presents the assertion to project 2 and receives an access token:
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: descope-xaa-redeem-assertion
namespace: apps
spec:
plugin:
oAuthTokenExchange:
method: rfc7523
url: https://api.descope.com/oauth2/v1/apps/token
clientID: urn:k8s:secret:descope-clients:resourceID
clientSecret: urn:k8s:secret:descope-clients:resourceSecret
usernameClaim: sub
forwardHeaders:
X-Idjag-Sub: sub
X-Idjag-Client: azp
rfc7523:
scopes:
- idjag.read
- Authenticate as project 2's app, the client ID that you mapped to the Resource in step 2.
- Set
usernameClaimtosubso the access log'sClientUsernamenames the person. Without it, the record identifies no principal. forwardHeaderscopies claims from the access token into request headers. The upstream service receives the access token in theAuthorizationheader as well.- Set
rfc7523.scopesto the scope you granted. Descope doesn't require it, but without it the access token'sscopeclaim can also contain default scopes such asprofileandemail. Descope rejects a scope that the app in project 2 doesn't define with errorE063307.
Wire the Middlewares onto the Route
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: descope-xaa
namespace: apps
spec:
entryPoints:
- websecure
routes:
- match: Host(`xaa.example.com`)
kind: Rule
services:
- name: echo
port: 80
middlewares:
- name: descope-xaa-request-assertion
- name: descope-xaa-redeem-assertion
List the request middleware first, then the redeem middleware, then any middleware that authorizes.
A middleware placed before the redeem middleware sees the claims of the assertion instead of the access token's claims.
Leave store unset on the request middleware, because an assertion can be redeemed once.
The gateway removes the X-Subject-Token header before forwarding the request.
On a route that a browser reaches, put an oidc middleware for project 1 ahead of the request middleware, and set its forwardIDTokenHeader to X-Subject-Token.
The oidc middleware then overwrites the header with the session's ID token, so a caller can't supply their own.
Step 5: Verify the Flow
Send a request with a Descope session token for the user from step 2:
curl -i -H "X-Subject-Token: $SESSION_TOKEN" https://xaa.example.com/
The route answers 200. A request without the header is rejected with 401. The echo service shows an Authorization: Bearer header holding the access token from project 2, plus X-Idjag-Sub and X-Idjag-Client.
To inspect the assertion, run the first exchange by hand against https://api.descope.com/oauth2/v1/apps/token with project 1's client credentials, and decode the result with a JWT decoder.
The assertion carries these claims:
| Claim | Content |
|---|---|
aud | Project 2's issuer. |
aud_tenant | The tenant identifier from step 1. A missing or different value makes the second exchange fail. |
sub | The user's identifier in project 1. |
client_id | The client ID of project 2's inbound app. |
resource | The URI of the Resource from step 2. |
scope | idjag.read. |
You now have a route where the upstream service receives an access token issued by project 2.
Read the Access Token the Upstream Service Receives
The access token differs from the one that a single-organization setup produces. Read its claims before you design authorization behind the gateway.
- Identify the person with the access token's
sub, which is a project 2 identifier. With just-in-time provisioning on, project 2 creates its own user for each caller on first use, so thissubdiffers from thesubin the assertion. - Decide which agent acts before the second exchange. The access token's
azpis project 2's inbound app, the same value for every caller, and the assertion'sclient_idisn't carried forward. Control which agent acts through the token exchange policy and the scopes at the first exchange, or in a middleware placed between the request middleware and the redeem middleware, where the assertion's claims are still available. - Expect an empty
emailclaim. The access token'semailis empty. - Define scopes on the Resource. The token exchange policy limits the scopes that the Resource defines. A scope declared on the agent's own app isn't limited by the policy, so the policy isn't a ceiling for it.
Troubleshooting
The route answers 404 page not found
A middleware with a configuration error disables the whole router, so the route no longer matches. Open the router's entry in the runtime API (/api/http/routers) and read its error field.
The route answers 401 although the session token is valid
The gateway returns 401 when either exchange is refused and doesn't include Descope's reason in the response.
Set the gateway log level to DEBUG and read the Unable to exchange token entry for the error that Descope returned. The entries below list errors you can find there.
Requested target does not resolve to a registered resource
The request middleware has no rfc8693.resource, or its value differs from the Resource's URI in project 1. Set rfc8693.resource to the exact URI, including the port.
E063310 Requested scope not granted
The scope in rfc8693.scopes isn't granted by the token exchange policy. Add the scope to the policy's grant and to the Resource's scopes, or request a granted scope.
E063307: Requested scope is not known to the application
The redeem middleware's rfc7523.scopes names a scope that the inbound app in project 2 doesn't define. Set rfc7523.scopes to idjag.read, the scope you added to that app in step 1, or remove the field.
E011003 Subject token type is not accepted
Descope accepts an ID token or a SAML assertion as the subject token for an assertion and refuses a refresh token. Send a session token, which is an ID token, and set rfc8693.subjectTokenType to urn:ietf:params:oauth:token-type:id_token.
E063314 No client ID registered for the target
The agent's app has no mapping for the Resource, or the mapping is keyed by a value that differs from the Resource's URI. Map the Resource from step 2 to the client ID of project 2's inbound app, and match the Resource's URI exactly.
The first exchange succeeds and the second exchange is refused
Check that Cross-App Access is on for the tenant in project 2, that project 1 is a trusted issuer there, and that just-in-time provisioning is on or the user already exists in the tenant.
Also check that the assertion's client_id is project 2's inbound app, which comes from the Resource mapping in step 2. The client authenticating the second exchange must be that same app.
Check that the assertion's aud_tenant is present and is the tenant identifier from step 1, which comes from the Resource's target tenant.
Next Steps
- Decide on the person in a policy engine with AuthZEN authorization.
- See all options in the OAuth token exchange reference.
- Set up the same flow with another identity provider in Cross App Access with Okta.
