Skip to main content

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.

  1. Create a tenant. Copy its tenant identifier.
  2. Open the tenant's SSO settings, turn on Cross-App Access, and add project 1's issuer and JWKS URL as a trusted issuer.
  3. 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.
  4. Create an inbound app that redeems the assertion, and add the scope idjag.read.
  5. 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.

  1. Create an inbound app for the agent, and add the scope idjag.read. Copy its client ID and secret. Descope shows the secret once.
  2. Create a Resource of type api whose 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 to https://api.descope.com/v1/apps/<project 2 ID>, which becomes the assertion's aud. Set its target tenant to the tenant identifier from step 1, which becomes the assertion's aud_tenant claim. If you leave the target tenant empty, the second exchange fails. Descope then sends the user's own tenant from project 1, or no aud_tenant when the user has none. Define the scope idjag.read on the Resource.
  3. Create a token exchange policy that permits the exchange for that Resource and grants the scope idjag.read.
  4. 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_id claim, and project 2 compares it with the client that authenticates the second exchange. Without the mapping, the first exchange fails with error E063314.
  5. 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.subjectTokenType to 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 with 400.
  • Set rfc8693.resource to 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.scopes here. 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 usernameClaim to sub so the access log's ClientUsername names the person. Without it, the record identifies no principal.
  • forwardHeaders copies claims from the access token into request headers. The upstream service receives the access token in the Authorization header as well.
  • Set rfc7523.scopes to the scope you granted. Descope doesn't require it, but without it the access token's scope claim can also contain default scopes such as profile and email. Descope rejects a scope that the app in project 2 doesn't define with error E063307.

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:

ClaimContent
audProject 2's issuer.
aud_tenantThe tenant identifier from step 1. A missing or different value makes the second exchange fail.
subThe user's identifier in project 1.
client_idThe client ID of project 2's inbound app.
resourceThe URI of the Resource from step 2.
scopeidjag.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 this sub differs from the sub in the assertion.
  • Decide which agent acts before the second exchange. The access token's azp is project 2's inbound app, the same value for every caller, and the assertion's client_id isn'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 email claim. The access token's email is 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​