On 14 July 2025 Dirk-jan Mollema sent Azure AD Graph an impersonation token whose tenant ID was not the tenant named on the signed Actor token. He expected a generic access denied. Once the token named a user who existed in the target tenant, the API returned that user’s data.
Actor tokens are JWTs issued by Microsoft’s Access Control Service. He came across that service while investigating hybrid Exchange, which used to put a certificate credential on the Exchange Online service principal. He writes that the hybrid setup was not required for the request: a credential on that service principal was enough, and Exchange was not the only Microsoft application that could ask for one. Exchange requests an Actor token when it calls another service as a user. He describes the service as a legacy issuer used with SharePoint and inside Microsoft’s own service to service calls. He also writes that he does not know its exact origins.
The signed token he published names a single audience: the Azure AD Graph application GUID, the host graph.windows.net, and the tenant the token was issued for. In his lab that tenant was iminyour.cloud, 6287f28f-4f7f-4322-9651-a8697d8fe1bc, so the audience read 00000002-0000-0000-c000-000000000000/graph.windows.net@6287f28f-4f7f-4322-9651-a8697d8fe1bc. Expiry was 24 hours after issuance. The issuer was the Azure ESTS service plus the same tenant. The claim trustedfordelegation was true. He reads that claim as permission to impersonate other identities, and he notes that a non-Microsoft application received the same claim set to false.
The request Azure AD Graph saw was a second JWT. He writes that Exchange embeds the Actor token in an impersonation token with algorithm none and no signature. For this API the identity that matters is nameid, the user’s netId. The sip, smtp and upn fields are ignored. One signed Actor token can therefore be wrapped, for those 24 hours, as any user the caller can name.
He changed the tenant ID of the impersonation token to b9fb93c1-c0c8-4580-99f3-d1b540cada32, a tenant that held none of his test accounts. The Actor token still carried the lab tenant. He sent the pair to graph.windows.net with his tool roadtx. The error said the identity could not be found. He read that as a valid token for a user the tenant did not contain. He substituted the netId of a user who did exist there. Azure AD Graph returned the data. He repeated the call in other test tenants he could access. A public tenant ID and a netId were enough.
He then used the same Actor token as a guest in the target tenant. That guest could query a Global Admin’s netId. A second impersonation token for that admin listed users, which the guest could not do. He writes that none of those actions would generate logs in the victim tenant. He also writes that requesting an Actor token produces no log the victim can see, that the tokens bypass Conditional Access, and that they cannot be revoked during their 24 hours of validity. He had no access to a national cloud tenant, so he did not test those deployments. He writes that a call from the public cloud into a national cloud is unlikely, because those deployments use their own signing keys, and that the same failure across tenants inside one national cloud is speculation.
He reported the issue to the Microsoft Security Response Center the same day. His timeline gives the rest of the dates. The case was opened on 14 July. On 15 July he sent further detail on the impact, and MSRC asked him to stop testing. Microsoft pushed a fix to global production on 17 July. MSRC confirmed the issue resolved on 23 July. On 6 August a further mitigation stopped service principal credentials from obtaining Actor tokens for Azure AD Graph. CVE-2025-55241 was issued on 4 September 2025. He published the write-up on 17 September 2025: One Token to rule them all.
The NVD record was published the same day, 4 September 2025, with source secure@microsoft.com. The description is one line: “Azure Entra ID Elevation of Privilege Vulnerability.” The record is tagged exclusively-hosted-service. NVD stores two CVSS 3.1 scores, and both entries are type Secondary. Microsoft’s vector is CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H, base score 10.0, scope changed. NVD’s own vector keeps the impact ratings and sets scope unchanged, base score 9.8. The record was last modified on 18 October 2026. In the write-up he says, “Based on Microsoft’s internal telemetry, they did not detect any abuse of this vulnerability.” That is his report of their telemetry. NVD’s description stops at the elevation-of-privilege title. His write-up is where the tenant mismatch and the unsigned wrapper are documented.
The audience on the signed token already named one tenant. Azure AD Graph answered a request whose impersonation token named another. He concluded that a caller who could learn a netId could do this in any tenant, and he describes netIds as incremental rather than random. That conclusion, and the method behind it, stay in his write-up. The calls above show something narrower: in tenants he could test, the directory did not hold the call to the tenant written on the signed token.
Two servers, one authorization server
An MCP server is a different product, and CVE-2025-55241 does not name one. A call reached Azure AD Graph carrying a tenant ID the signed audience did not name, and the API answered. The MCP authorization spec dated 2026-07-28 tells the resource server to make that comparison. The audience has to contain this server, as an exact string.
The payments MCP server and the docs MCP server can share an authorization server. A token whose audience is the docs server can still arrive at payments. I would keep the tool handler closed until that audience contains the exact identifier configured for payments.
The claim sets below are constructed. They are not the Entra tokens, and they say nothing about whether a named product implements this check.
The payments server in the fixture expects https://pay.example/mcp. Six claim sets are compared with that string. The function never reads signature, expiry, issuer or scope. A deployment can pass those checks and still skip the audience.
| Case | Membership | Single audience |
|---|---|---|
| Exact match | allow | allow |
| Other server | deny | deny |
| Longer path | deny | deny |
| Trailing slash | deny | deny |
| Two servers | allow | deny |
| Absent | deny | deny |
The presented values are https://pay.example/mcp, https://docs.example/mcp, https://pay.example/mcp-backup, https://pay.example/mcp/, an array of the docs and payments audiences, and a claim set with no aud.
Both the longer path and the trailing slash begin with the expected audience. They are different strings, and the fixture denies them. A check that asks whether the presented audience starts with the expected audience accepts both of those rows. The opposite prefix test accepts a shorter value such as https://pay.example. Exact membership rejects the longer path, the trailing slash, and that shorter value.
The two-server row is allowed by membership because the payments audience is present. It is denied when the caller sets the local single-audience policy. That policy is an extra rule in the fixture, separate from the membership requirement in the specification.
The neighbouring-server row is the MCP form of a token minted for a different recipient. In July the mismatched field was a tenant ID, and the signed audience already held a different tenant. The trailing-slash row is a nearer miss on a single server. Both should end before a tool runs.
The 2026-07-28 rules
The MCP authorization specification dated 2026-07-28 requires clients to implement Resource Indicators for OAuth 2.0 (RFC 8707). The resource parameter has to be sent on both the authorization request and the token request. It has to identify the MCP server the client intends to use, using the canonical URI defined with RFC 8707 Section 2. Clients send the parameter even when the authorization server has no support for it.
The same specification requires the MCP server, as resource server, to validate access tokens as described in Section 5.2 of draft-ietf-oauth-v2-1-13, the draft it cites. That section requires a check that the token is unexpired, authorized for the requested resource, issued with the appropriate scope, and acceptable under the resource server’s other policy. The MCP text then states a further requirement: validate that the token was issued for this server as the intended audience, according to RFC 8707 Section 2. The specification requires HTTP 401 when a token is invalid or expired.
The authorization server and the resource server have different duties here. RFC 8707 Section 2 says the authorization server should audience-restrict issued tokens to the requested resource. The audience may be the exact resource value, or the authorization server may map that value to a more general URI or an abstract identifier. RFC 7519 Section 4.1.3 defines the JWT form. aud is an array of case-sensitive strings, or one case-sensitive string when there is a single recipient. When the claim is present, the JWT is rejected if the processing principal is absent from aud. JWT also says use of aud is optional.
MCP does not treat a missing audience as optional. The authorization security considerations say the server must reject a token that does not include this server in the audience claim, unless some other check verifies that this server is the intended recipient. The fixture implements the audience-claim check. Sender-constrained tokens and a private introspection field are other mechanisms, and this fixture does not perform them.
The token passthrough section of the MCP security best practices covers the next boundary. Accepting a token minted for another service, and forwarding the client’s token unchanged to an upstream API, both violate the specification. An upstream API needs a token its own authorization server issued for that API.
Run the parsed-claim check
Download mcp-token-audience.py, read it, and run:
python3 mcp-token-audience.py
python3 mcp-token-audience.py --test
python3 -O mcp-token-audience.py --test
The script uses the Python 3.9 standard library only. It does not decode a JWT, verify a signature, or open a network connection. Thirteen tests live in the file. They cover the table, the prefix traps, and the single-audience policy. python3 -O still runs them, because the checks are unittest assertions rather than the assert statement.
audience_decision(expected, aud, single_audience=False) compares one configured audience with a claim value you have already taken from a verified token or from an introspection response. decision_from_claims reads the aud member of a dictionary. iss, scope and every other member stay out of the result.
The membership test is the complete string, not a prefix:
matched = expected in values
single_audience=True is off by default. Turn it on only when you want the local policy described below.
A missing aud returns audience_missing and allow: false. An empty array, a number, a Boolean, surrounding whitespace, a control character, a fragment, or more than 32 audiences raises ValueError. Each audience string is limited to 2,048 characters. Both limits belong to this teaching function. RFC 7519 does not define them. Treat an exception as a failed check. Map allow: false to the HTTP 401 the specification requires. The script does not build that response. A successful run means the function returned.
Compare complete strings
Against https://pay.example/mcp, the tests deny all of these:
https://pay.example/mcp-backuphttps://pay.example/mcp/https://pay.example/mcp?x=1https://pay.example:443/mcphttps://Pay.example/mcphttps://pay.example- the same spelling with a Cyrillic small letter a, U+0430, in
pay
The specification notes that https://mcp.example.com/ and https://mcp.example.com are both absolute URIs, and it recommends the form without a trailing slash unless the slash carries meaning. The recommendation settles what you configure. It does not make the two strings equal. Use the characters the authorization server writes into aud.
RFC 7519 audience strings are case-sensitive. The MCP specification also says the canonical form uses a lowercase scheme and host, and that implementations should accept uppercase scheme and host components. The safe place to apply that is a fold of scheme and host only, on the configured audience and on each presented audience, followed by an exact comparison. Folding the entire string would also fold the path, and the path is how two servers on one host stay distinct. This fixture compares the strings as written. If the authorization server emits a different host case from the server configuration, make those two strings the same in configuration.
The unit tests record the trap directly. https://pay.example/mcp-backup.startswith(https://pay.example/mcp) is true, and https://pay.example/mcp.startswith(https://pay.example) is true. Both calls are denied by audience_decision. Membership asks whether the expected audience is one of the complete presented values.
Decide what to do with several audiences
RFC 8707 Section 3 says a bearer token with more than one audience is valid at each listed resource, and that any one of those resources can present the token to the others. It requires a high degree of trust between those parties. It also says an authorization server may be unwilling or unable to issue a token for multiple resources.
Membership in the fixture follows the claim. If the expected audience appears, the audience portion passes, including when other audiences appear beside it. For enterprise MCP servers I would reject multi-audience bearer tokens unless someone has reviewed the trust relationship among the authorization server and every listed recipient. single_audience=True is that rejection. The result reason is single_audience_policy, which stays distinct from audience_mismatch. A mismatch is still reported as a mismatch when the expected audience is absent, so the policy flag cannot hide a wrong recipient.
Two copies of the same audience count as one recipient. Duplicates do not trip the single-audience policy.
For a multi-tenant host, RFC 8707 says to include the tenant identifier in the resource URI, using the path component when that is what separates tenants. https://pay.example is a wider credential than https://pay.example/mcp/tenants/northstar. The fixture matches the string you configure. A host-wide expected audience matches a host-wide token, which means the tenant boundary was already lost in configuration. That is a different mistake from the July failure. There the signed audience already contained a tenant, and a second structure was allowed to name another one. Exact membership will not notice a tenant that your code reads from an unsigned wrapper and then fails to compare. Keeping a pooled session on the previous tenant is a separate failure, described in Claude Code changed tenants. The MCP session did not.
Leave the other checks in place
decision_from_claims denies a payload whose scope is payments.admin and whose issuer is the company authorization server, when aud names the docs server. The same function allows an exact audience match while ignoring exp. Expiry, issuer, signature, revocation and proof of possession remain separate gates. An audience_match from this script means the configured string occurred in aud.
The specification tells the client to send resource even when the authorization server ignores the parameter. If that happens, the minted audience can differ from the resource the client requested. Refusal at the resource server is what stops the token being used.
Where the bytes are sent, and which audience the token names, are different controls. Claude Code approved one MCP server. The request went somewhere else covers a connection that leaves the approved host. The audience check assumes the request has arrived at the server you configured.
If the agent is presenting a person’s general API credential, that credential was minted for a different recipient. Give the agent its own identity, not your credentials is the identity rule. The audience check is the per-request form of that rule. The credential has to name the server that is about to act. Your MCP server is part of the security boundary is the wider statement of the same boundary.
A token that passes this check is still an MCP access token. Forwarding it to a downstream API is the passthrough behaviour the security guidance forbids, including when the audience was right for the MCP server.
Configure the string the authorization server mints
Delegated identity and MCP token handling are developed in Securing Enterprise AI Agents. The Packt Kindle page is on Amazon UK.
Take one MCP server and record the audience its authorization server writes into new tokens. That value may be the canonical server URI, or a mapped identifier such as api://payments-mcp. The fixture accepts an abstract identifier when the configured string and the presented aud are the same characters. Configure the resource server with that string.
Present three parsed claim sets to the real validation path, ahead of any tool handler: a token minted for this server, a token minted for a neighbouring MCP server on the same issuer, and a token whose audience is the configured string with a trailing slash or an extra path segment. The first can continue into expiry, signature, scope and revocation checks. The other two should end in HTTP 401.