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.
| Piece | Status (checked September 29, 2026) |
|---|---|
| MCP authorization, version 2026-07-28 | Current MCP specification |
| Enterprise-Managed Authorization | Stable MCP extension, promoted June 17, 2026 |
| draft-ietf-oauth-identity-assertion-authz-grant-04 | IETF 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 metadata | Published RFCs |
| Password-grant sign-in, HTTP, secrets in source | Demo 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:
| User | Group | What the IdP will put in an ID-JAG for the Notes MCP server |
|---|---|---|
| alice | engineering | notes.read notes.write |
| bob | contractors | notes.read |
| carol | sales | nothing. 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:
- The header
typisoauth-id-jag+jwt. issis an IdP this server trusts, and the signature verifies against that IdP’s JWKS.audis 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.- The
client_idclaim matches the client that authenticated the request. The draft calls this client continuity. jtihasn’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:
| Attempt | Response |
|---|---|
| Replay the same ID-JAG at the authorization server | invalid_grant: jti ... already used |
| Present the ID token to the authorization server | invalid_grant: typ must be oauth-id-jag+jwt, got JWT |
Ask the IdP for an ID-JAG for http://evil-as.example | invalid_target: no trust relationship with http://evil-as.example |
| Send the ID-JAG or the ID token to the MCP server | 401, 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_jwtat the authorization server, and agentgateway supportsprivateKeyJwt. - 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.shuses--force-recreatefor that reason. - The MCP session uses protocol version
2025-11-25, and agentgateway v1.5.0 advertisesmcp_protocol_version: 2025-06-18in 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.