MCP Enterprise-Managed Authorization: Claude Auto-connect, ID-JAG and Tenant Isolation

- Share:





3366 Members
Enterprise-Managed Authorization (EMA) replaces per-user OAuth consent screens with a single organization approval. An identity provider or Claude issues a signed identity assertion, your authorization server validates it, and the user connects to your MCP server without clicking through a consent dialog. The MCP specification calls the assertion format Identity Assertion JWT Authorization Grant (ID-JAG), and Claude calls the user-facing feature auto-connect.
This article covers how the identity assertion exchange works, what tenant isolation requirements you need to enforce, which checks prevent assertion replay and cross-tenant confusion, and where application-level authorization picks up after the token exchange completes. If you need the OAuth 2.1 foundation for MCP, including Protected Resource Metadata, PKCE, and Client ID Metadata Documents, see MCP OAuth 2.1: PKCE, Resource Metadata and Authorization.
Enterprise-Managed Authorization is an extension to MCP authorization that lets organizations skip per-user consent. Instead of each user going through an OAuth flow with a consent screen, the organization approves the MCP connector once. After that, users connect automatically.
The technical mechanism is ID-JAG: Identity Assertion JWT Authorization Grant. It is based on RFC 7523, the JWT bearer authorization grant. Your authorization server accepts a signed JWT at its token endpoint with grant type urn:ietf:params:oauth:grant-type:jwt-bearer, validates the assertion, and returns an access token. The MCP server then receives the same kind of bearer token it would after an interactive OAuth flow.
Two issuers can produce the identity assertion:
https://identity.anthropic.com/issuers/{org-specific-id}. Claude-issued assertions are currently in beta.The token format and exchange are the same in both cases. What differs is who issued the assertion and what it proves about the user.
The exchange happens at your authorization server's token endpoint. Claude sends a form-encoded POST with the JWT bearer grant type, the signed assertion, and the registered client identifier:
POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
&assertion=eyJhbGciOi...
&client_id=your-registered-client-id
&scope=read%20write
&resource=https://mcp.example.com
The client_id parameter is the registered client identifier, not a credential. If the client is confidential (has a secret or key), the request must also include client authentication. The Claude documentation specifies two methods: client_secret_post sends the secret in the request body, while private_key_jwt sends a signed client assertion verified against the keys at the client's jwks_uri. Your authorization server advertises supported methods in token_endpoint_auth_methods_supported.
The resource parameter is always present for Claude-issued requests, set to your MCP server's URL or a parent of it from your Protected Resource Metadata. For IdP-issued requests, the parameter is present only when the customer's identity provider supports forwarding it. Accept the request whether or not resource is present, and use it for audience binding when it is.
Your authorization server must advertise the grant type in its metadata. The RFC 8414 authorization server metadata document should include it in grant_types_supported:
{
"issuer": "https://auth.example.com",
"token_endpoint": "https://auth.example.com/token",
"grant_types_supported": [
"authorization_code",
"refresh_token",
"urn:ietf:params:oauth:grant-type:jwt-bearer"
]
}
Claude checks that your metadata meets these conditions before sending assertions: the issuer matches the URL Claude fetched the metadata from, and the token_endpoint is on the same registrable domain as the issuer. If those checks pass and you advertise the grant type, Claude will use it.
When your authorization server validates the assertion and issues an access token, include expires_in so Claude knows when to get a new token. Claude does not use refresh tokens from this grant. It obtains a new identity assertion without user interaction and exchanges it at your token endpoint for a fresh access token.
The decoded payload of an ID-JAG assertion looks like this:
{
"iss": "https://identity.anthropic.com/issuers/abc123",
"sub": "opaque-user-identifier",
"email": "alex@example.com",
"aud": ["https://auth.example.com"],
"client_id": "your-registered-client-id",
"scope": "read write",
"aud_tenant": "your-tenant-id",
"iat": 1790000000,
"nbf": 1789999985,
"exp": 1790000045,
"jti": "unique-assertion-identifier"
}
The typ header must be oauth-id-jag+jwt. Claude-issued assertions always carry email and use ES256 for signing. The scope and aud_tenant claims are optional.
For IdP-issued assertions, sub matches the subject your product receives when that user signs in through SSO, so you can find the user by iss and sub together. For Claude-issued assertions, sub is an opaque value that does not match your SSO records. Find the user by email instead.
The aud_tenant claim carries your own identifier for the customer workspace, useful when one customer has multiple workspaces that trust the same issuer.
The most critical security requirement is exact issuer binding. Each customer workspace in your product must be bound to exactly one trusted issuer URL. When an assertion arrives, you resolve it to a workspace and then verify that the assertion's iss claim exactly matches that workspace's registered issuer as an exact string, including any trailing slash.
The comparison must be byte-for-byte equality:
# Schematic example - not a production implementation
def validate_issuer(assertion_iss: str, workspace_issuer: str) -> bool:
# Exact string equality, including trailing slash if present
return assertion_iss == workspace_issuer
Without exact matching, subtle mistakes create cross-tenant vulnerabilities. Consider a shared identity infrastructure where https://sso.acme-corp.com/ serves multiple subsidiaries. If your validation only checks that the issuer starts with a common prefix, an assertion from https://sso.acme-corp.com/subsidiary-a would be accepted when the workspace registered https://sso.acme-corp.com/subsidiary-b. Normalizing or removing trailing slashes would incorrectly equate https://sso.acme-corp.com with https://sso.acme-corp.com/, allowing an assertion from one to satisfy a workspace that registered the other. The Claude documentation notes: "Keep the issuer value exactly as published, trailing slash included."
Run the issuer check before you fetch any keys. The docs are explicit: "A prefix or host match admits other customers' users. Check this before you fetch any keys."
If you identify workspaces by your own tenant ID and the assertion carries aud_tenant, use it to select the workspace. Otherwise, look up the workspace that registered the iss value. If no workspace registered it, or if more than one did and aud_tenant does not disambiguate, reject the assertion.
Two checks protect against token misdirection and must be performed explicitly rather than relying on permissive library defaults.
The aud claim must equal your authorization server's own issuer value, exactly as your metadata publishes it. The claim can arrive as either a string or a JSON array. If it arrives as an array, verify the array contains exactly one element and that element matches your issuer. Do not accept an array with multiple audiences, even if one of them matches. Some JWT libraries accept any matching audience from a multi-element array by default, which weakens the binding. Verify the match yourself or configure your library to require a single-element audience.
The client_id claim in the assertion must equal the client_id parameter of the token request. The client_id is the registered client identifier for Claude at your authorization server. For Claude-issued assertions, the claim names the client sending the request. For IdP-issued assertions, the claim is the client ID that the administrator registered in their identity provider for your connector. When administrators set up your connector through Claude's managed authorization flow, Claude provides the client ID to use, but administrators who register your connector through other means must enter the correct client ID in their IdP settings.
If either check fails, reject the request with invalid_grant as RFC 7523 section 3 requires.
Every identity assertion carries a jti (JWT ID) claim. Your authorization server must accept each jti from an issuer exactly once. The Claude documentation states: "If you can't check whether a jti was already used, reject the request."
This requirement implies fail-closed behavior: if your replay store is unavailable, you must reject the request rather than accept it without the replay check.
Production replay protection requires atomic consume semantics in a shared store that survives restarts and works across replicas. The operation must atomically check whether the (iss, jti) pair exists and insert it if not, returning whether the insert succeeded. A local dictionary with check-then-set logic is unsafe because concurrent requests can both see the key as absent before either inserts it, and the dictionary is lost on restart.
The following pseudocode illustrates the required semantics, but is not a production implementation:
# Schematic example - use your platform's atomic store (Redis SETNX,
# database INSERT with unique constraint, etc.)
def consume_jti(iss: str, jti: str, exp_plus_skew: datetime) -> bool:
"""
Atomically consume (iss, jti). Returns True if this is the first
consume, False if already consumed. Raises if store unavailable.
Retain the entry until exp_plus_skew (assertion exp + configured
clock skew allowance, at most 60 seconds per Claude requirements).
"""
# ATOMIC: check-and-set in shared store with expires_at = exp_plus_skew
# If store is unreachable, raise an exception (fail closed)
...
def check_replay(iss: str, jti: str, exp: datetime, skew_seconds: int = 60) -> bool:
exp_plus_skew = exp + timedelta(seconds=skew_seconds)
try:
return consume_jti(iss, jti, exp_plus_skew)
except StoreUnavailableError:
# Fail closed: cannot verify replay status, must reject
return False
Treat jti as an opaque string. Retain entries until the assertion's exp plus your configured clock skew allowance.
How you find the user depends on who issued the assertion.
IdP-issued assertions: The sub claim is the IdP's identifier for the user. It matches the subject your product receives when that user signs in through SSO. Find the user by iss and sub together, among members of the workspace the assertion resolved to.
Claude-issued assertions: The sub claim is opaque and does not match your SSO records. Find the user by email instead. Compare the whole address, ignore letter case, and keep dots and plus tags as they are.
The email verification semantics matter here. The Claude documentation warns: "Sign a user in by email only when your service has verified that user's email address, or when the address is on an email domain the workspace has verified. An issuer URL isn't a secret, and whoever runs an identity provider can put any address in email."
If no member of the workspace has the email address, you have two options: reject the request, or create the user only when the address is on an email domain the workspace has verified with your service. Do not blindly create accounts for any email address in Claude-issued assertions.
A Claude-issued assertion proves exactly one thing: the person is a current member of the customer's Claude organization, and email is the address of their Claude account.
It does not prove that the user signed in through the customer's identity provider, used multi-factor authentication, or belongs to any particular group. The Claude documentation is clear: "The assertion doesn't say that the user signed in through the customer's identity provider or used multi-factor authentication, and it carries no group claims."
Switching to IdP-issued assertions does not automatically provide these guarantees either. An IdP-issued assertion proves that the IdP authenticated the user and vouches for the sub claim, but whether the IdP enforced MFA, verified group membership, or applied conditional access policies depends on that IdP's configuration. If your application requires assurance of MFA completion, group membership, or specific authentication context, you must verify that information separately through your own records or by inspecting additional claims the IdP includes in the assertion. The identity assertion flow confirms who the user is; it does not replace your application's authorization decisions about what that user may do.
Never write your own signature verification. Use the JWT validation built into your authorization server product or its vendor's validation library.
To get the keys, fetch /.well-known/openid-configuration from the iss URL, confirm that the document's issuer claim exactly equals the assertion's iss, then fetch the key set from the document's jwks_uri. Use only keys from that key set. The Claude documentation is explicit: "ignore any key or key URL carried in the assertion's header." An attacker who can inject a key into the JWT header (via jwk, jku, or x5u claims) could sign their own assertions and have your server trust them. Your validation must use only the keys discovered from the issuer's published JWKS endpoint, never keys supplied in the assertion itself.
Do not pin a single key. Cache keys according to HTTP cache headers, and fetch them again when an assertion names a kid you have not seen. Accept ES256 and RS256. Reject every other algorithm, including none and HMAC algorithms like HS256.
Claude-issued assertions use ES256. A Claude issuer's discovery document holds issuer and jwks_uri but has no authorization_endpoint or token_endpoint, so read those fields yourself rather than passing the document to a validator expecting a full OpenID Connect provider configuration.
Run these checks in order on every token request. When any check fails, reject the request with invalid_grant:
| Step | Verification |
|---|---|
| 1. Workspace resolution | Find the workspace by aud_tenant if present, otherwise by iss. Reject if none found or ambiguous. |
| 2. Issuer | iss exactly equals the workspace's registered issuer, including trailing slash. Check before fetching keys. |
| 3. Keys | Fetch from /.well-known/openid-configuration, confirm document's issuer equals iss, use only keys from jwks_uri. Ignore any key or key URL in the assertion header. |
| 4. Signature | Verifies against a key from that key set. Accept ES256 and RS256 only. |
| 5. Type | typ header is oauth-id-jag+jwt. |
| 6. Audience | aud is your authorization server's issuer value exactly. If aud is an array, verify it contains exactly one element. |
| 7. Client | client_id claim equals the request's client_id parameter. |
| 8. Subject | sub claim is present. |
| 9. Time | exp not passed, iat not in future, nbf (if present) has passed. Allow at most 60 seconds clock skew. |
| 10. Replay | jti is present and atomically consumed for this issuer. If replay store is unavailable, reject (fail closed). |
| 11. User lookup | For IdP-issued: find by iss + sub. For Claude-issued: find by verified email. |
Issue the access token for that workspace only, even when the same user belongs to other workspaces.
Test these rejection cases before launch:
When an assertion arrives with an iss that no workspace registered, or that multiple workspaces registered without a disambiguating aud_tenant, reject with invalid_grant. This is the cross-tenant confusion case.
When an assertion's iss does not exactly match the workspace's registered issuer (including any trailing slash difference), reject with invalid_grant.
When the aud claim does not match your authorization server's issuer URL, or when aud is an array with more than one element, reject with invalid_grant.
When the same jti from the same issuer arrives a second time, reject with invalid_grant. This is replay detection working correctly.
When your replay store is unavailable and you cannot verify whether the jti was already consumed, reject with invalid_grant. This is fail-closed behavior, which the Claude documentation requires: "If you can't check whether a jti was already used, reject the request."
For Claude-issued assertions, when the email does not match any verified user in the workspace and the domain is not verified by the workspace, reject with invalid_grant.
When a user was removed from the customer's organization, Claude stops signing new assertions for them. However, access tokens you already issued stay valid until expiration. Your access token lifetime bounds how long a removed user retains access after removal.
Issue access tokens with whatever lifetime your security policy requires. A short lifetime like one hour does not make users sign in again each time a token expires. Claude obtains a new identity assertion without user interaction and exchanges it for a fresh access token.
When a user is removed from the customer's Claude organization, Claude stops signing Claude-issued assertions for them. But access tokens already issued stay valid. If you issue one-hour tokens, a removed user has up to one hour of continued access. If you issue 24-hour tokens, they have up to 24 hours.
For high-security applications, consider shorter token lifetimes and active revocation checks.
EMA and ID-JAG solve the identity assertion exchange. They give your authorization server a way to verify who the user is and issue an access token. But they do not decide whether that user's AI agent should be allowed to call a specific tool with specific arguments at this moment.
The access token proves the session is valid. Per-tool authorization decides whether delete_customer_data should execute after the agent read untrusted content. That second decision needs policy evaluation at the tool-call layer, examining the user, the agent, the tool, the arguments, the tenant context, and runtime conditions.
The MCP OAuth 2.1 guide explains how OAuth handles the session layer. Scopes define broad categories of access. But the scope issues.write does not answer whether this agent should create an issue in the production infrastructure repository, whether the issue body contains data it should not expose, or whether this is the twentieth mutation in thirty seconds after reading a suspicious external source.
Per-tool authorization belongs at the MCP server or at a gateway layer that intercepts tool calls before they reach the upstream server. That layer evaluates each tools/call request against policy before execution. OAuth scopes are one input. The actual permission decision requires more context.
EMA and Claude auto-connect have availability constraints you should understand before building against them:
Claude Team and Enterprise plans: EMA is available only on Claude Team and Enterprise plans. Free and Pro plans cannot use it.
Claude-issued assertions are in beta: The Claude documentation marks Claude-issued assertions as beta. The feature works, but expect changes before general availability.
WorkOS MCP Auth: WorkOS announced on November 3, 2026 that MCP Auth supports Claude auto-connect in early access. This extends EMA to organizations whose identity provider does not support Cross App Access (XAA).
Dynamic Client Registration not supported: DCR is not supported with EMA. When the customer's IdP issues assertions, it stamps a fixed client_id into every one, so your authorization server must recognize that client before the first assertion arrives. Use pre-registered clients or Client ID Metadata Documents instead.
If you are building an MCP server that will accept EMA, confirm your authorization server infrastructure supports the JWT bearer grant. Many hosted identity platforms do, but you should verify that yours can register an issuer URL that does not belong to the customer's IdP as a trusted issuer.

AI Engineer, Agent Whisperer, Tesseract Architect, All Things Cyber