MCP Cross App Access Explained: The ID-JAG Exchange on the Wire

MCP Enterprise-Managed Authorization (Cross App Access) traced request by request: the ID-JAG exchange, the JWT bearer grant, and agentgateway validating it.

MCP’s Enterprise-Managed Authorization lets the corporate IdP decide which employees can use which MCP servers, by making the client trade an IdP-signed ID-JAG for each server’s access token. The MCP agent identity post named that exchange and didn’t explain it. Here it is on the wire, from a local demo with a toy enterprise IdP, a toy MCP authorization server, and an MCP server behind agentgateway v1.5.0. Every request and token is captured, including the requests that get refused.

Short answer: Cross App Access (XAA) is the product name for MCP’s Enterprise-Managed Authorization extension. It takes three HTTP calls. The client signs the user in at the corporate IdP and gets an ID token. It swaps that ID token at the IdP for an ID-JAG (RFC 8693 token exchange). Then it swaps the ID-JAG at the MCP server’s authorization server for an access token (RFC 7523 JWT bearer grant). The IdP decides who gets access at the middle step. That means one admin console can grant or revoke access to every MCP server, and nobody clicks through a consent screen per server.

Which parts are spec, which are draft, which are the demo

This topic mixes three levels of maturity, so here they are before any wire traffic.

PieceStatus (checked September 29, 2026)
MCP authorization, version 2026-07-28Current MCP specification
Enterprise-Managed AuthorizationStable MCP extension, promoted June 17, 2026
draft-ietf-oauth-identity-assertion-authz-grant-04IETF OAuth WG Internet-Draft, revision 04 of May 21, 2026, expires November 22, 2026
RFC 8693 token exchange, RFC 7523 JWT bearer, RFC 9728 resource metadataPublished RFCs
Password-grant sign-in, HTTP, secrets in sourceDemo shortcuts, listed at the end

So a stable MCP extension profiles an unfinished IETF draft. EMA links revision 04 by section number, and I use those section names below.

The core MCP spec doesn’t mention ID-JAG. Its MCP Authorization Extensions section says extensions are optional and additive and points to the ext-auth repo. What the core spec does require still applies. The MCP server has to publish Protected Resource Metadata (“Authorization Server Location”). It has to check that tokens were issued for it (“Token Handling”). It can’t pass tokens through (“Token Audience Binding and Validation”).

The demo

git clone https://github.com/themsquared/mcp-oauth-xaa.git
cd mcp-oauth-xaa
./scripts/run-all.sh

That builds five containers and runs five scenarios. Each scenario writes a transcript to captures/. The IdP’s policy table is a Python dict in services/idp.py:

UserGroupWhat the IdP will put in an ID-JAG for the Notes MCP server
aliceengineeringnotes.read notes.write
bobcontractorsnotes.read
carolsalesnothing. The exchange is refused

Everything below comes from captures/alice.md and the server logs in the repo. The keys and client secrets are local test values made for this demo, so I’ve left them in. I only shortened the long JWT strings. The full strings are in the transcripts.

Step 1: the MCP server tells the client where to authenticate

The client calls the MCP server with no token. agentgateway sits in front of the server as the OAuth resource server:

HTTP/1.1 401 Unauthorized
Content-Type: application/json
WWW-Authenticate: Bearer resource_metadata="http://gateway:3000/.well-known/oauth-protected-resource/mcp"

The metadata document names the authorization server:

{
  "resource": "http://gateway:3000/mcp",
  "authorization_servers": [
    "http://as:8002"
  ],
  "mcp_protocol_version": "2025-06-18",
  "resource_type": "mcp-server",
  "bearer_methods_supported": ["header"],
  "scopes_supported": ["notes.read", "notes.write"]
}

The client then fetches that server’s RFC 8414 metadata and looks for the profile identifier that EMA Section 6, “Discovery,” tells it to check:

"grant_types_supported": ["urn:ietf:params:oauth:grant-type:jwt-bearer"],
"authorization_grant_profiles_supported": ["urn:ietf:params:oauth:grant-profile:id-jag"]

Draft Section 7.2, “Resource Authorization Server Metadata,” says a server that lists the id-jag profile has to list jwt-bearer in grant_types_supported too. It also says the listing only means the server implements the profile. It doesn’t promise that any particular issuer or client will be accepted.

This is the agentgateway config for that front door. I checked each field against schema/config.md at the v1.5.0 tag :

mcpAuthentication:
  mode: strict
  issuer: http://as:8002
  audiences:
  - http://gateway:3000/mcp
  jwks:
    url: http://as:8002/jwks.json
  resourceMetadata:
    resource: http://gateway:3000/mcp
    scopesSupported:
    - notes.read
    - notes.write
    bearerMethodsSupported:
    - header

Step 2: sign in at the IdP

This is where the demo takes a shortcut. EMA Section 3, “User Authentication,” expects OIDC or SAML single sign-on through a browser. The demo uses the password grant so it can run headless. The result is the same kind of token, an ID token issued to the client:

{
  "iss": "http://idp:8001",
  "sub": "U-1001",
  "aud": "acme-agent",
  "email": "[email protected]",
  "groups": ["engineering"],
  "amr": ["pwd"]
}

aud is acme-agent, the client. That’s why the next step exists. As draft Section 3 puts it, ID tokens are only meant for the relying party, not for an authorization server in another trust domain.

Step 3: exchange the ID token for an ID-JAG

This is the call EMA Section 4, “Token Exchange,” defines. It’s a standard RFC 8693 token exchange with a new requested_token_type:

POST /token HTTP/1.1
Authorization: Basic YWNtZS1hZ2VudDphY21lLWFnZW50LWlkcC1zZWNyZXQ=
Host: idp:8001
Content-Type: application/x-www-form-urlencoded

grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange
&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid-jag
&audience=http%3A%2F%2Fas%3A8002
&resource=http%3A%2F%2Fgateway%3A3000%2Fmcp
&scope=notes.read+notes.write
&subject_token=eyJhbGciOiJSUzI1NiIsImtpZCI6ImFjbWUtaWRwLTEi...
&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid_token

Two parameter rules in EMA are easy to mix up. audience MUST be the issuer identifier of the MCP server’s authorization server. resource is optional, and if it’s present it MUST be the MCP server’s resource identifier. So one names the authorization server and the other names the MCP server.

Under draft Section 4.3.3, “Processing Rules,” the IdP first checks that the ID token’s audience matches the client that authenticated this request. Then it runs admin policy. For alice, policy allows both scopes, and the IdP returns:

{
  "issued_token_type": "urn:ietf:params:oauth:token-type:id-jag",
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFjbWUtaWRwLTEiLCJ0eXAiOiJvYXV0aC1pZC1qYWcrand0In0...",
  "token_type": "N_A",
  "scope": "notes.read notes.write",
  "expires_in": 300
}

token_type is N_A because the result isn’t an access token. The draft points out that RFC 8693 requires the access_token field name “for historical reasons.” Decoded, the ID-JAG looks like this:

{
  "alg": "RS256",
  "kid": "acme-idp-1",
  "typ": "oauth-id-jag+jwt"
}
{
  "jti": "e46c64d826524fcbab8382edc67176d2",
  "iss": "http://idp:8001",
  "sub": "U-1001",
  "email": "[email protected]",
  "aud": "http://as:8002",
  "client_id": "acme-agent-notes",
  "scope": "notes.read notes.write",
  "iat": 1790700724,
  "exp": 1790701024,
  "auth_time": 1790700724,
  "amr": ["pwd"],
  "resource": "http://gateway:3000/mcp"
}

Compare it with the ID token. aud changed from the client to the authorization server. typ is now oauth-id-jag+jwt, which draft Section 3.1, “ID-JAG Claims,” requires. client_id is acme-agent-notes, not acme-agent. The draft’s Section 5, “Cross-Domain Client ID Handling,” covers that: the claim holds the client’s identifier at the authorization server, which can differ from its identifier at the IdP. The client has two registrations, and the IdP maps one to the other.

For carol, the IdP stops here:

{
  "error": "invalid_grant",
  "error_description": "policy: groups ['sales'] have no access to http://as:8002"
}

The draft doesn’t define an error code for a policy refusal. invalid_grant is the demo’s choice. What matters is where the refusal happens: at the IdP. Carol never gets a token, so the MCP server and its authorization server never see her.

Step 4: exchange the ID-JAG for an access token

The client sends the ID-JAG to the MCP server’s authorization server as an RFC 7523 assertion (EMA Section 5, “Access Token Request”). It authenticates with its credentials at that server:

POST /token HTTP/1.1
Authorization: Basic YWNtZS1hZ2VudC1ub3RlczphY21lLWFnZW50LW5vdGVzLXJhcy1zZWNyZXQ=
Host: as:8002
Content-Type: application/x-www-form-urlencoded

grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer
&assertion=eyJhbGciOiJSUzI1NiIsImtpZCI6ImFjbWUtaWRwLTEiLCJ0eXAiOiJvYXV0aC1pZC1qYWcrand0In0...

services/ras.py applies the checks in draft Section 4.4.1, “Processing Rules,” in this order:

  1. The header typ is oauth-id-jag+jwt.
  2. iss is an IdP this server trusts, and the signature verifies against that IdP’s JWKS.
  3. aud is this server’s own issuer, either as a string or as an array with exactly one element. The draft says this check prevents audience injection.
  4. The client_id claim matches the client that authenticated the request. The draft calls this client continuity.
  5. jti hasn’t been seen before. The demo enforces single use. That’s allowed but not required.

EMA Section 5.1 adds one rule: the access token MUST be audience-restricted to the MCP server named in the ID-JAG’s resource claim. Here’s the response and the decoded token:

{
  "token_type": "Bearer",
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6Im5vdGVzLWFzLTEiLCJ0eXAiOiJhdCtqd3QifQ...",
  "expires_in": 300,
  "scope": "notes.read notes.write"
}
{
  "iss": "http://as:8002",
  "sub": "U-1001",
  "aud": "http://gateway:3000/mcp",
  "client_id": "acme-agent-notes",
  "scope": "notes.read notes.write",
  "email": "[email protected]",
  "idp": "http://idp:8001",
  "jti": "b30a806c028349418c27efba0f908165",
  "iat": 1790700724,
  "exp": 1790701024
}

There’s no refresh token, on purpose. Draft Section 4.4.3, “Refresh Token,” says the authorization server SHOULD NOT issue one. The client presents the ID-JAG again, or gets a new ID-JAG from the IdP. That’s the point of the whole design. Every renewal goes back through the IdP, so revoking access at the IdP ends access within one token lifetime.

I found one inconsistency between the documents. The core MCP spec’s “Resource Parameter Implementation” section says clients MUST include resource in token requests. EMA’s example jwt-bearer request doesn’t include it, because the resource travels inside the ID-JAG. My client follows the EMA example. If your authorization server enforces the core rule strictly, send resource on this request too.

Step 5: MCP calls, filtered by scope

With the access token, initialize, tools/list and tools/call go through. agentgateway validates the token on every request and filters tools with two CEL rules:

mcpAuthorization:
  rules:
  - allow: 'mcp.tool.name == "list_notes" && "notes.read" in jwt.scope.split(" ")'
  - allow: 'mcp.tool.name == "add_note" && "notes.write" in jwt.scope.split(" ")'

Bob’s token only carries notes.read, so his tools/list shows one tool:

{"tools":[{"name":"list_notes","description":"List all team notes.","inputSchema":{"type":"object","properties":{}}}]}

When bob calls add_note anyway, he gets this:

{"code": -32602, "message": "Unknown tool: add_note"}

That’s the same misleading “unknown tool” error I wrote about in the identity gap post. The IdP made a policy decision several hops back, and the model sees a missing tool. SEP-2643 would fix that. It’s still open.

The MCP server’s own log shows that the token never reached it:

[notes-mcp] tools/list | Authorization header received: no
[notes-mcp] tools/call | Authorization header received: no

The core spec requires this. MCP servers “MUST NOT accept or transit any other tokens,” and here the gateway is the resource server that consumes the token.

What gets rejected

The --attacks scenario sends the tokens to places they don’t belong:

AttemptResponse
Replay the same ID-JAG at the authorization serverinvalid_grant: jti ... already used
Present the ID token to the authorization serverinvalid_grant: typ must be oauth-id-jag+jwt, got JWT
Ask the IdP for an ID-JAG for http://evil-as.exampleinvalid_target: no trust relationship with http://evil-as.example
Send the ID-JAG or the ID token to the MCP server401, gateway log: token uses the unknown key "acme-idp-1"

The last row needs a caveat. The gateway rejected those tokens because the IdP’s signing key isn’t in the authorization server’s JWKS, so it never reached the audience check. If one server signs both kinds of token, as in agentgateway’s own Keycloak example where one realm plays both roles, the aud check is the only thing between an ID-JAG and your MCP server. Configure audiences either way.

Let agentgateway run the exchange

So far the client ran both exchanges. agentgateway v1.4.0 added a policy that does it on the client’s behalf, backendAuth.crossAppAccess . The client sends only the user’s ID token. The demo’s second listener, :3030, is set up this way and forwards to the protected :3000:

jwtAuth:
  issuer: http://idp:8001
  audiences:
  - acme-agent
  jwks:
    url: http://idp:8001/jwks.json
backendAuth:
  crossAppAccess:
    subjectToken:
      source:
        expression: jwt.rawToken.unredacted()
    identityProvider:
      host: idp:8001
      path: /token
      clientAuth:
        clientId: acme-agent
        method: clientSecretBasic
        clientSecret: acme-agent-idp-secret
    resourceAuthorizationServer:
      host: as:8002
      path: /token
      clientAuth:
        clientId: acme-agent-notes
        method: clientSecretBasic
        clientSecret: acme-agent-notes-ras-secret
    audience: http://as:8002
    resources:
    - http://gateway:3000/mcp
    scopes:
    - notes.read
    - notes.write
    accessTokenScopes: []

You need subjectToken.source.expression because jwtAuth strips the credential after validating it. accessTokenScopes: [] leaves out scope on the second request, which the agentgateway docs recommend for authorization servers that treat the ID-JAG’s own scope claim as authoritative. Here’s what the IdP received from the gateway:

POST /token
Host: idp:8001
Authorization: Basic YWNtZS1hZ2VudDphY21lLWFnZW50LWlkcC1zZWNyZXQ=

grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange
&subject_token=eyJhbGciOiJSUzI1NiIsImtpZCI6ImFjbWUtaWRwLTEi...
&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid_token
&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid-jag
&audience=http%3A%2F%2Fas%3A8002
&scope=notes.read+notes.write
&resource=http%3A%2F%2Fgateway%3A3000%2Fmcp

The client made four MCP requests. The IdP logged one token exchange and the authorization server logged one JWT bearer grant, because the gateway cached the access token. Caching matters when you have a lot of agents, since each exchange is a round trip to the corporate IdP. It also means the IdP sees one exchange, not every request.

This is my main reason to put the exchange in a gateway. The client ends up holding one credential, the ID token, and never touches an ID-JAG, an authorization-server client secret, or an MCP access token. The two client registrations sit in one gateway config, not in every agent.

The agentgateway docs list what crossAppAccess doesn’t support yet: DPoP (RFC 9449), .well-known endpoint discovery (you configure the endpoints yourself), and SAML or refresh-token subject tokens.

Christian Posta covered the enterprise side of this in Connecting SaaS MCP Servers to Enterprise With Agentgateway , and walked through an Okta SAML and Keycloak ID-JAG setup . The Keycloak image in agentgateway’s own local example, ceposta/keycloak:id-jag, is his.

What the demo simplifies

  • Sign-in is the password grant. OAuth 2.1 removes it. Use a real OIDC or SAML SSO flow.
  • Everything is plain HTTP with compose service names as issuers. Real issuers use HTTPS.
  • Client secrets are in source. EMA also allows Client ID Metadata Documents and private_key_jwt at the authorization server, and agentgateway supports privateKeyJwt.
  • Keys are generated at startup. If you restart the IdP or authorization server without restarting the gateway, every token fails with InvalidSignature, because agentgateway loads the JWKS when it starts. run-all.sh uses --force-recreate for that reason.
  • The MCP session uses protocol version 2025-11-25, and agentgateway v1.5.0 advertises mcp_protocol_version: 2025-06-18 in its resource metadata. The authorization behavior exercised here (resource metadata, audience-bound tokens, the 401 challenge) is in the 2026-07-28 authorization spec. The stateless transport changes in 2026-07-28 aren’t part of the demo.
  • The toy servers don’t implement DPoP, SAML, refresh tokens, or multi-tenant issuers, though the draft covers all four.

Why this matters

For a security team, EMA gives one enforcement point for “who can use which AI client with which MCP server,” and a revocation that takes effect within one access-token lifetime. EMA Section 7.2 also states the limit: the IdP sees token issuance, not MCP traffic. So you still want the gateway checking scopes per tool and logging each call.

It also doesn’t answer the question from the last post. This flow authenticates alice. The agent acts with her delegated authority. An unattended agent with no employee behind it still needs workload identity, which is SEP-1933 ’s job, and that’s still an open pull request.

The code, the configs, and every transcript quoted here are in themsquared/mcp-oauth-xaa . Next I want to point the same gateway config at a hosted IdP that issues ID-JAGs, to see which parts of the demo’s authorization server a real one does differently.

Frequently asked questions

What is Cross App Access (XAA) in MCP?

It is the vendor name for MCP Enterprise-Managed Authorization, a stable MCP authorization extension. The MCP client signs the user in through the corporate IdP, exchanges the ID token at the IdP for an Identity Assertion JWT Authorization Grant (ID-JAG) with RFC 8693 token exchange, then presents the ID-JAG to the MCP server's authorization server as an RFC 7523 JWT bearer assertion to get an access token. The IdP applies group policy at the exchange step.

What is the difference between an ID token and an ID-JAG?

An ID token is issued to the client, so its aud is the client_id, and it is not meant for any other party. An ID-JAG is issued for the MCP server's authorization server: its aud is that server's issuer, its header typ is oauth-id-jag+jwt, and it carries client_id, scope and resource. In the demo, presenting the ID token to the authorization server fails with typ must be oauth-id-jag+jwt.

Can agentgateway perform the MCP Cross App Access exchange for a client?

Yes. agentgateway v1.4.0 added a backendAuth.crossAppAccess policy. With jwtAuth validating the user's OIDC ID token, the gateway runs the token exchange at the IdP and the JWT bearer grant at the resource authorization server, then forwards the request with the resulting access token. In the demo, four MCP requests through the gateway produced one exchange at each server because the gateway caches the token.

Is the ID-JAG grant an IETF standard?

Not yet. The MCP extension is marked stable, but the grant it profiles, draft-ietf-oauth-identity-assertion-authz-grant, is an OAuth working group Internet-Draft. Revision 04 was published on May 21, 2026 and expires on November 22, 2026. The token exchange and JWT bearer steps it builds on, RFC 8693 and RFC 7523, are published RFCs.