Fix mTLS PoP token cache: key on certificate DER (x5t#S256), not the public key#6123
Merged
Conversation
…renewal Reproduces AADSTS500181 (CertificateValidationFailedTlsCertMismatch) in the two-leg mTLS PoP flow. When the binding certificate is renewed with the same key but a new DER (serial/validity), MSAL's mTLS PoP token cache - keyed by ComputeX5tS256KeyId, which hashes only the public key - cannot distinguish the two certificates and returns the stale token bound to the previous certificate while the renewed certificate is on the wire. The test fails on current code (second acquisition returns TokenSource.Cache) and will pass once the cache key is derived from the certificate DER. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…f public key CoreHelpers.ComputeX5tS256KeyId hashed the certificate public key (GetPublicKey()) to produce the mTLS PoP cache KeyId. ESTS/MSS bind and validate the PoP token against the SHA-256 of the full DER-encoded certificate (x5t#S256, RFC 8705). These diverge on a same-key certificate renewal (same public key, new DER), so the public-key-keyed cache returns a token bound to the previous certificate while the renewed certificate is presented on the wire, producing AADSTS500181. Hash certificate.RawData (the DER) so a renewal always yields a distinct KeyId, causing a cache miss and a re-mint bound to the current certificate. This matches the wire x5t#S256 already computed by JsonWebToken. The KeyId is cache-only and never sent on the wire, so the only runtime effect is a one-time cache miss when a certificate is renewed. Also updates the cache-match diagnostics in ClientCredentialRequest and the test helper to reflect DER-based key identity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…helper Adds mTLSPop_ProactiveRefresh_RotatesCert_ServedResultStaysConsistentAsync: in the pure Managed Identity mTLS PoP flow, a cache-hit result served alongside a background proactive refresh that rotates the binding certificate stays a consistent (token, BindingCertificate) pair - the served result's BindingCertificate is a snapshot and is never repointed to the newly-minted cert. This rules out the 'proactive refresh rotates the cert mid-flow' hypothesis as a source of the cert/assertion mismatch. Also fixes TestCommon.WithRefreshOn, which dropped AdditionalCacheKeyComponents (unlike its sibling WithExpiresOn), causing PoP/extended tokens to be duplicated under a different cache key instead of overwritten. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR fixes an mTLS Proof-of-Possession (PoP) token-cache invalidation bug by aligning MSAL’s cache “certificate identity” with what ESTS/MSS actually binds to (x5t#S256 over the certificate DER), preventing stale PoP tokens from being served after same-key certificate renewals.
Changes:
- Update
CoreHelpers.ComputeX5tS256KeyIdto hashX509Certificate2.RawData(DER) instead of the public key. - Adjust CCA cache-match diagnostics to reflect the DER-based identity.
- Add/adjust unit tests to prove the same-key renewal scenario and to document proactive-refresh behavior, plus a helper fix to preserve
AdditionalCacheKeyComponentswhen mutatingRefreshOn.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsPopTests.cs | Updates KeyId expectation to DER hash and adds a regression test for same-key cert renewal to ensure cache bypass/re-mint. |
| tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ImdsV2Tests.cs | Adds a proactive-refresh + cert-rotation invariant test to show served results remain self-consistent during background refresh. |
| tests/Microsoft.Identity.Test.Common/TestCommon.cs | Fixes WithRefreshOn test helper to preserve AdditionalCacheKeyComponents, avoiding unintended cache-key changes/duplication in tests. |
| src/client/Microsoft.Identity.Client/Utils/CoreHelpers.cs | Implements DER-based KeyId computation (x5t#S256) for mTLS PoP cache identity. |
| src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs | Updates verbose logging text for the KeyId/certificate cache gate to reflect the DER-based identity. |
bgavrilMS
reviewed
Jul 15, 2026
bgavrilMS
reviewed
Jul 15, 2026
bgavrilMS
reviewed
Jul 15, 2026
bgavrilMS
approved these changes
Jul 15, 2026
…icate2 extension - Move ComputeX5tS256KeyId from CoreHelpers to a dedicated Utils/X509Certificate2Extensions class and call it as cert.ComputeX5tS256KeyId() (bgavrilMS). - Trim the verbose comment in the helper (bgavrilMS). - Clarify the KeyId comment wording in ClientCredentialRequest (bgavrilMS). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
4gust
approved these changes
Jul 15, 2026
1 task
This was referenced Jul 16, 2026
Open
Merged
This was referenced Jul 16, 2026
Closed
Closed
fix: Bump Microsoft.Identity.Client from 4.86.0 to 4.86.1
Halceyon/open-telemetry-trace-listener#239
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
MSAL keys its mTLS Proof-of-Possession (PoP) token cache on a value derived from the certificate's public key. ESTS/MSS, however, bind and validate the PoP token against the full DER certificate (
x5t#S256, RFC 8705). On a same-key certificate renewal (identical public key, new DER), these diverge: MSAL keeps serving a cached PoP token bound to the previous certificate while the renewed certificate is presented on the wire, and the server rejects the call withAADSTS500181.The fix keys the cache on the certificate DER, so any certificate change — including a same-key renewal — is a cache miss and the token is re-minted against the current certificate.
Symptom
A two-leg mTLS PoP flow fails intermittently with:
Both token acquisitions log
TokenSource=Cache, yet the downstream mTLS call is rejected. It is transient — it lasts until the stale cached token expires and is re-minted.Background: the two-leg flow
The caller runs two MSAL acquisitions and then calls a downstream mTLS endpoint:
AcquireTokenForManagedIdentity(...).WithMtlsProofOfPossession()→ returns an MSI access token and the binding certificate. (The token is used only as the client assertion for Leg 2; the certificate is presented on the downstream mTLS handshake.)AcquireTokenForClient(...).WithMtlsProofOfPossession()(client assertion = the Leg 1 token) → returns the PoP token. (This token is sent to the downstream endpoint.)cnf.The two things the server compares come from different caches: the certificate from Leg 1, the token from Leg 2.
Root cause
MSAL's mTLS-PoP cache identity is
CoreHelpers.ComputeX5tS256KeyId, which hashes the public key:ClientCredentialRequest.ShouldUseCachedTokenuses this KeyId as a "did the certificate change?" gate — if the current certificate's KeyId differs from the cached token's KeyId, it bypasses the cache and re-mints. The intent is correct, but the identity is wrong.ESTS/MSS bind to the
x5t#S256over the DER certificate — which MSAL already computes correctly for the wire inJsonWebToken(GetCertHash(SHA256)/SHA256(RawData)). On a same-key renewal (cert 1 → cert 2, identical public key, new DER/serial), the public-key hash is unchanged, so the gate concludes "same certificate, reuse" and serves the stale cert-1-bound token.Walkthrough
Both legs are cache hits (matching the log). The MSI cache (Leg 1) correctly rotated to cert 2; the CCA cache (Leg 2) failed to notice the same-key rotation and kept serving the cert-1-bound token.
Fix
Derive the cache KeyId from the certificate DER:
Now cert 1 and cert 2 have different KeyIds → the
ShouldUseCachedTokengate fires → cache bypass → the PoP token is re-minted bound to cert 2 → wire certificate and tokencnfmatch.Wire-safe: this KeyId is used only as a cache/lookup identity; it is never sent on the wire (the wire
x5t#S256already uses the DER). The only runtime effect is a one-time cache miss when a certificate is renewed.Alternatives considered and ruled out
(assertion, cert)pair; not the cause.AuthenticationResult.BindingCertificateis a plain snapshot property. A background refresh mints a new certificate intoRuntimeMtlsBindingCertificateand its own result; it never repoints an already-returned result. Verified by a dedicated test (below).Tests
MtlsPop_SameKeyCertRenewal_MustNotServeStaleCachedTokenAsync— the proving test. Builds two certificates sharing one RSA key pair but differing in DER, mints a PoP token bound to cert A, then presents cert B. Fails on the old code (second acquisition returnsTokenSource.Cache— the stale cert-A token) and passes with the fix (TokenSource.IdentityProvider, re-bound to cert B).mTLSPop_ProactiveRefresh_RotatesCert_ServedResultStaysConsistentAsync— documents that a cache-hit result served alongside a background proactive refresh that rotates the binding certificate stays a consistent(token, BindingCertificate)pair, and the servedBindingCertificatereference is never repointed to the newly-minted certificate (asserted after the rotation completes, by thumbprint andReferenceEquals).Full unit suite: 2237 passed, 0 failed.
Changes
CoreHelpers.ComputeX5tS256KeyId— hashcertificate.RawData(DER) instead ofGetPublicKey().ClientCredentialRequest— cache-match diagnostics updated to reflect DER-based key identity (behavior unchanged; it calls the corrected helper).MtlsPopTests— new proving test +ComputeExpectedKeyIdhelper updated to DER.ImdsV2Tests— new proactive-refresh consistency test.TestCommon.WithRefreshOn— preserveAdditionalCacheKeyComponents(matchingWithExpiresOn), fixing duplicate cache entries for PoP/extended tokens.Commits
WithRefreshOnhelper fix.