Summary
The ChallengeAuthPolicy request-replay bug fixed by #47742 ("Fix Challenge Auth replay bug and update tests", merged 2026-07-08) was applied to azure-keyvault-keys and azure-keyvault-administration, but not to azure-keyvault-secrets or azure-keyvault-certificates, which vendor their own copies of _shared/challenge_auth_policy.py.
Separately, the fix is not present in any stable release yet — every current stable Key Vault package predates the merge, including azure-keyvault-keys itself.
To be clear about severity: I am not reporting this as a security vulnerability. The replayed request goes back to the vault that originally received it, and the bearer token is the shared https://vault.azure.net/.default audience that vault was already sent. verify_challenge_resource additionally blocks the cross-resource case. This is a correctness bug — an intended request is silently replaced by a duplicate of an earlier one.
Current state at HEAD
Occurrence count of _request_copy in each package's _shared/challenge_auth_policy.py:
| Package |
_request_copy at HEAD |
Status |
azure-keyvault-keys |
1 |
fixed by #47742 |
azure-keyvault-administration |
0 |
fixed |
azure-keyvault-securitydomain |
0 |
not affected |
azure-keyvault-secrets |
4 |
unported |
azure-keyvault-certificates |
4 |
unported |
In the unfixed copies the stash is stored on the policy instance, which is created once per client rather than once per request:
self._request_copy: Optional[HttpRequest] = None — client-level state
self._request_copy = request.http_request — stores the in-flight request
if self._request_copy: request.http_request = self._request_copy — transplants it onto a later request
Stable releases
| Package |
Latest stable |
Uploaded |
Contains fix? |
azure-keyvault-secrets |
4.11.0 |
2026-04-17 |
no |
azure-keyvault-certificates |
4.11.1 |
2026-05-05 |
no |
azure-keyvault-keys |
4.11.1 |
2026-05-19 |
no — predates the 2026-07-08 merge |
azure-keyvault-administration |
4.7.0 |
2026-05-19 |
no — predates the merge |
The fix currently ships only in the pre-release azure-keyvault-keys 4.12.0b3.
Reproduction
This is the regression test added by #47742 (test_request_body_not_reused_across_requests), re-pointed at the unported packages. No Azure account, no network — the transport is a Mock.
pip install azure-keyvault-secrets==4.11.0 azure-keyvault-certificates==4.11.1 azure-keyvault-keys==4.12.0b3
Send a bodied POST to vault-A (elicits a challenge), then a bodiless GET to vault-B through the same client, and inspect the 4th outbound request:
=== azure-keyvault-keys 4.12.0b3 (POSITIVE CONTROL - contains #47742) ===
4th request method : GET (expected GET)
4th request url : https://vault-b.vault.azure.net/secrets/unrelated
4th request body : None (expected None)
RESULT: ok (per-request stash)
=== azure-keyvault-secrets 4.11.0 (UNPORTED) ===
4th request method : POST (expected GET)
4th request url : https://vault-a.vault.azure.net/secrets/db-password
4th request body : b'a duck' (expected None)
RESULT: prior request replayed
=== azure-keyvault-certificates 4.11.1 (UNPORTED) ===
... identical: POST, vault-a URL, body b'a duck'
The positive control discriminates: the fixed package passes under the identical harness, so the result is a property of the code under test rather than of the test.
Full reproduction script
import time
from unittest.mock import Mock
from azure.core.pipeline import Pipeline
from azure.core.rest import HttpRequest
from azure.core.credentials import AccessToken
CHALLENGE = Mock(
status_code=401,
headers={
"WWW-Authenticate": 'Bearer authorization="https://authority.net/tenant", '
"resource=https://vault.azure.net"
},
)
def exercise(policy_cls, label):
first_content = b"a duck"
first_url = "https://vault-a.vault.azure.net/secrets/db-password"
second_url = "https://vault-b.vault.azure.net/secrets/unrelated"
seen = {}
class C:
n = 0
def send(request):
C.n += 1
if C.n == 1:
return CHALLENGE
if C.n == 2:
return Mock(status_code=200)
if C.n == 3:
return CHALLENGE
if C.n == 4:
seen["method"], seen["url"], seen["body"] = request.method, request.url, request.body
return Mock(status_code=200)
raise ValueError("unexpected request")
cred = Mock(spec_set=["get_token"],
get_token=Mock(return_value=AccessToken("token", time.time() + 3600)))
pipeline = Pipeline(policies=[policy_cls(credential=cred)], transport=Mock(send=send))
req = HttpRequest("POST", first_url)
req.set_bytes_body(first_content)
pipeline.run(req)
pipeline.run(HttpRequest("GET", second_url))
replayed = seen.get("body") == first_content or seen.get("url") == first_url
print(f"\n=== {label} ===")
print(f" 4th request method : {seen.get('method')} (expected GET)")
print(f" 4th request url : {seen.get('url')}")
print(f" expected : {second_url}")
print(f" 4th request body : {seen.get('body')!r} (expected None)")
print(" RESULT: " + ("prior request replayed" if replayed else "ok (per-request stash)"))
return replayed
if __name__ == "__main__":
from azure.keyvault.keys._shared.challenge_auth_policy import ChallengeAuthPolicy as KeysPolicy
from azure.keyvault.secrets._shared.challenge_auth_policy import ChallengeAuthPolicy as SecretsPolicy
from azure.keyvault.certificates._shared.challenge_auth_policy import ChallengeAuthPolicy as CertsPolicy
exercise(KeysPolicy, "azure-keyvault-keys (POSITIVE CONTROL - fixed by #47742)")
exercise(SecretsPolicy, "azure-keyvault-secrets (UNPORTED)")
exercise(CertsPolicy, "azure-keyvault-certificates (UNPORTED)")
Impact
A call intended for one vault is emitted as a duplicate of an earlier call — the intended operation does not happen, and the earlier one is repeated. For secrets and certificates the replayed body is secret material being written a second time, so an unintended duplicate write or rotation is possible.
Suggested fix
Port #47742 to azure-keyvault-secrets and azure-keyvault-certificates (make the stash per-request rather than per-policy), and ship it in a stable release — the fix currently exists only in the pre-release azure-keyvault-keys 4.12.0b3.
Root cause of the divergence is the duplicated _shared directories: each package vendors its own copy of challenge_auth_policy.py, so a fix in one does not propagate. The .NET and Java SDKs are unaffected — they use a single shared implementation.
Summary
The
ChallengeAuthPolicyrequest-replay bug fixed by #47742 ("Fix Challenge Auth replay bug and update tests", merged 2026-07-08) was applied toazure-keyvault-keysandazure-keyvault-administration, but not toazure-keyvault-secretsorazure-keyvault-certificates, which vendor their own copies of_shared/challenge_auth_policy.py.Separately, the fix is not present in any stable release yet — every current stable Key Vault package predates the merge, including
azure-keyvault-keysitself.To be clear about severity: I am not reporting this as a security vulnerability. The replayed request goes back to the vault that originally received it, and the bearer token is the shared
https://vault.azure.net/.defaultaudience that vault was already sent.verify_challenge_resourceadditionally blocks the cross-resource case. This is a correctness bug — an intended request is silently replaced by a duplicate of an earlier one.Current state at HEAD
Occurrence count of
_request_copyin each package's_shared/challenge_auth_policy.py:_request_copyat HEADazure-keyvault-keysazure-keyvault-administrationazure-keyvault-securitydomainazure-keyvault-secretsazure-keyvault-certificatesIn the unfixed copies the stash is stored on the policy instance, which is created once per client rather than once per request:
self._request_copy: Optional[HttpRequest] = None— client-level stateself._request_copy = request.http_request— stores the in-flight requestif self._request_copy: request.http_request = self._request_copy— transplants it onto a later requestStable releases
azure-keyvault-secretsazure-keyvault-certificatesazure-keyvault-keysazure-keyvault-administrationThe fix currently ships only in the pre-release
azure-keyvault-keys4.12.0b3.Reproduction
This is the regression test added by #47742 (
test_request_body_not_reused_across_requests), re-pointed at the unported packages. No Azure account, no network — the transport is aMock.Send a bodied
POSTto vault-A (elicits a challenge), then a bodilessGETto vault-B through the same client, and inspect the 4th outbound request:The positive control discriminates: the fixed package passes under the identical harness, so the result is a property of the code under test rather than of the test.
Full reproduction script
Impact
A call intended for one vault is emitted as a duplicate of an earlier call — the intended operation does not happen, and the earlier one is repeated. For
secretsandcertificatesthe replayed body is secret material being written a second time, so an unintended duplicate write or rotation is possible.Suggested fix
Port #47742 to
azure-keyvault-secretsandazure-keyvault-certificates(make the stash per-request rather than per-policy), and ship it in a stable release — the fix currently exists only in the pre-releaseazure-keyvault-keys4.12.0b3.Root cause of the divergence is the duplicated
_shareddirectories: each package vendors its own copy ofchallenge_auth_policy.py, so a fix in one does not propagate. The .NET and Java SDKs are unaffected — they use a single shared implementation.