Library version used
latest
.NET version
net 8
Scenario
ConfidentialClient - service to service (AcquireTokenForClient)
Is this a new or an existing app?
The app is in production, and I have upgraded to a new version of MSAL
Issue description and reproduction steps
Bug: mTLS PoP — stale cert-bound token served from cache after binding-certificate rotation (AADSTS500181)
| Field |
Value |
| Component |
MSAL.NET — Confidential Client, mTLS Proof-of-Possession (bearer-over-mTLS path) |
| Severity / Priority |
2 / 1 (transient, customer-facing auth failures in S2S) |
| Status |
Fix in review — PR #6116 |
| Trace ID |
ba5e4558-0d10-4c1f-9bf0-118f90957100 |
| Correlation ID |
ad8a9a7a-962c-48ce-bb64-812091c8edb5 |
Summary
On the implicit bearer-over-mTLS path (a confidential client configured with WithClientAssertion(...) whose delegate returns a ClientSignedAssertion.TokenBindingCertificate, without .WithMtlsProofOfPossession()), MSAL caches the access token cert-agnostically. After the binding certificate rotates, MSAL serves the previously cached token — still bound (cnf) to the old certificate — while the caller presents the new certificate on the wire, so the resource rejects the call.
Symptom
mTLS PoP signing failed with HTTP 400:
{
"error": "CertificateValidationFailedTlsCertMismatch",
"error_description": "AADSTS500181: The TLS certificate provided does not match the certificate in the assertion. Trace ID: ba5e4558-0d10-4c1f-9bf0-118f90957100 Correlation ID: ad8a9a7a-962c-48ce-bb64-812091c8edb5 Timestamp: 2026-07-13 07:45:50Z",
"error_codes": [500181],
"timestamp": "2026-07-13 07:45:50Z",
"trace_id": "ba5e4558-0d10-4c1f-9bf0-118f90957100",
"correlation_id": "ad8a9a7a-962c-48ce-bb64-812091c8edb5"
}
Repro
- Two-leg S2S: Leg 1 = Managed Identity issues a JWT + binding cert; Leg 2 = CCA via
WithClientAssertion(_ => MI.GetJwtAndCert()) acquires a token over mTLS as a bearer token (no .WithMtlsProofOfPossession()).
AcquireTokenForClient → call the resource (MSS signing) over mTLS → succeeds.
- Rotate the MI binding cert (VM reboot / renewal → new cert + new key).
- Within the cached token's lifetime,
AcquireTokenForClient again → served from cache (stale, bound to old cert).
- Present the new cert to the resource → AADSTS500181.
Root cause
The implicit path leaves the auth scheme as the default BearerAuthenticationOperation, whose KeyId is null. The app-token cache partition key (CacheKeyFactory.GetAppTokenCacheItemKey) and the PoP filter (FilterTokensByPopKeyId) both key off the scheme's KeyId. With KeyId == null the cache is cert-agnostic, so a cert rotation still matches the stale entry. The token is in fact certificate-bound by ESTS, so wire-cert ≠ token cnf → rejection.
The explicit .WithMtlsProofOfPossession() path was unaffected — it already sets KeyId = certificate thumbprint.
Impact
Transient auth failures at the resource for the window between binding-cert rotation and the cached token's expiry (≈ up to the access-token lifetime). Affects S2S callers using the bearer-over-mTLS pattern with a rotating binding cert (e.g., Managed Identity binding certs).
Introduce MtlsBoundBearerAuthenticationOperation — identical to bearer on the wire (Authorization: Bearer, no token_type, BindingCertificate not surfaced) but exposing a KeyId derived from the binding certificate — and apply it on the implicit path so the token cache is partitioned per certificate. A rotation now misses the cache and re-mints, mirroring the explicit PoP path.
Validation
- Full MSAL.NET unit suite green (2236 passed).
- New regression tests: rotation → cache miss; same cert → cache hit.
Relevant code snippets
Expected behavior
No response
Identity provider
Microsoft Entra ID (Work and School accounts and Personal Microsoft accounts)
Regression
No response
Solution and workarounds
Workaround (until fix ships)
- Force a fresh token / new CCA instance when the binding certificate changes.
Library version used
latest
.NET version
net 8
Scenario
ConfidentialClient - service to service (AcquireTokenForClient)
Is this a new or an existing app?
The app is in production, and I have upgraded to a new version of MSAL
Issue description and reproduction steps
Bug: mTLS PoP — stale cert-bound token served from cache after binding-certificate rotation (AADSTS500181)
ba5e4558-0d10-4c1f-9bf0-118f90957100ad8a9a7a-962c-48ce-bb64-812091c8edb5Summary
On the implicit bearer-over-mTLS path (a confidential client configured with
WithClientAssertion(...)whose delegate returns aClientSignedAssertion.TokenBindingCertificate, without.WithMtlsProofOfPossession()), MSAL caches the access token cert-agnostically. After the binding certificate rotates, MSAL serves the previously cached token — still bound (cnf) to the old certificate — while the caller presents the new certificate on the wire, so the resource rejects the call.Symptom
Repro
WithClientAssertion(_ => MI.GetJwtAndCert())acquires a token over mTLS as a bearer token (no.WithMtlsProofOfPossession()).AcquireTokenForClient→ call the resource (MSS signing) over mTLS → succeeds.AcquireTokenForClientagain → served from cache (stale, bound to old cert).Root cause
The implicit path leaves the auth scheme as the default
BearerAuthenticationOperation, whoseKeyIdisnull. The app-token cache partition key (CacheKeyFactory.GetAppTokenCacheItemKey) and the PoP filter (FilterTokensByPopKeyId) both key off the scheme'sKeyId. WithKeyId == nullthe cache is cert-agnostic, so a cert rotation still matches the stale entry. The token is in fact certificate-bound by ESTS, so wire-cert ≠ tokencnf→ rejection.Impact
Transient auth failures at the resource for the window between binding-cert rotation and the cached token's expiry (≈ up to the access-token lifetime). Affects S2S callers using the bearer-over-mTLS pattern with a rotating binding cert (e.g., Managed Identity binding certs).
Fix (PR #6116)
Introduce
MtlsBoundBearerAuthenticationOperation— identical to bearer on the wire (Authorization: Bearer, notoken_type,BindingCertificatenot surfaced) but exposing aKeyIdderived from the binding certificate — and apply it on the implicit path so the token cache is partitioned per certificate. A rotation now misses the cache and re-mints, mirroring the explicit PoP path.Validation
Relevant code snippets
Expected behavior
No response
Identity provider
Microsoft Entra ID (Work and School accounts and Personal Microsoft accounts)
Regression
No response
Solution and workarounds
Workaround (until fix ships)