You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
SignatureDoesNotMatch when endpoint URL contains a portΒ #79
However, the actual HTTP request still follows RFC behavior and includes the port in the Host header. This results in a mismatch between the signed value and the value sent over the wire, causing the SignatureDoesNotMatch error.
I am wondering why the port is removed from the Host header during signature generation instead of using the value from the request URL directly, for example:
urlparse(str(req.url)).netloc
Adding the Host header manually before sending the request resolves the issue:
Could you please clarify whether stripping the port is intentional? If not, would it make sense to preserve the port from the request URL when calculating the signature?
It works in httpx but not in requests today. See #34#63#65#68 for the history. I think the assertNotEqual in 8e1417c (from #68) is actually inverted. The real trick is if we can identify the actual errors (and create unit tests for them) and make sure the fix encompasses all of them. I'll put an AI summary next; if you've got time it would be a great project to read through the issues and PRs to make sure it is correct.
AI summary of this issue and its history (#34, #63, #65, #68), with everything verified by running it rather than inferred from the threads. All claims below are reproducible with the scripts at the bottom.
Short version
The diagnosis in this issue is correct, and this is a rediscovery of #34 (2017). The fix already exists β 8e1417c from #63 β and was reverted in b328b86 over the regression reported in #65. It has been stalled there since.
The assertNotEqual in #68's test_netloc_port_is_kept_for_non_standard_port is indeed inverted. Its httpx twin asserts assertEqual on the same URL, so the suite currently asserts that the two clients must disagree β which is why 78 green tests can't see this bug.
Reproduced against real servers
RadosGW (quay.io/ceph/demo), the same server and port as this issue:
test
result
list buckets with requests
FAIL β SignatureDoesNotMatch
list buckets with httpx
pass
both clients sign identically
FAIL
explicit Host header (the workaround)
pass
MinIO on port 9111, same story:
request
HTTP
code
HEAD behavior (library computes host)
403
SignatureDoesNotMatch
host including port
200
OK
These are independent implementations of the spec rejecting the signature β not our model of http.client disagreeing with itself. Neither needs AWS credentials, and both start in well under a minute.
Where HEAD is wrong
http.client (what requests/urllib3 uses underneath) is ground truth for what goes on the wire:
URL
on the wire
HEAD signs
8e1417c signs
https://h
h
β h
β h
http://h
h
β h
β h
https://h:443
h
β h
β h
https://h:8443
h:8443
β h
β h:8443
http://h:80
h
β h
β h
http://h:7480
h:7480
β h
β h:7480
https://h:80
h:80
β h
β h:80
http://h:443
h:443
β h
β h:443
https://[::1]:8443
[::1]:8443
β [
β [::1]:8443
https://[::1]:443
[::1]
β [
β [::1]
https://[::1]
[::1]
β [
β [::1]
HEAD is wrong on 7 of 11. 8e1417c is correct on all 11. Row 6 is this issue.
Why only requests is affected
aws4auth.py:610 only runs when 'host' not in headers. httpx's Request._prepare() populates Host itself, correctly, port and all β so the library passes it through untouched. requests' PreparedRequest leaves Host unset (urllib3 adds it at send time), so the library computes it, and .netloc.split(':')[0] drops the port unconditionally.
Same URL, different signature, depending on which HTTP client is used.
Two bugs in here that aren't in any issue
IPv6 literals are entirely broken, ports aside. .netloc.split(':')[0] on [::1] returns [ β a single open bracket. Even https://[::1] with no port signs host:[. 8e1417c fixes this incidentally.
Cross-scheme ports.https://h:80 and http://h:443 are non-default for their scheme, so the port does go on the wire. The one-liner proposed in #34's comments ("check if the port is 80 or 443") gets both wrong; 8e1417c's scheme-aware check gets them right. Worth flagging before anyone lands the obvious-looking patch.
Fixing it automatically, without a flag
Two mechanisms that compose, and together remove the need for an opt-in:
A. Compute the host the way the client will. This is 8e1417c: strip the port only when it is the scheme's default. Correct on all 11 rows above, and it changes nothing about what is sent over the wire. Its one durable weakness is that it's a reimplementation of http.client's rule, so it has to stay in sync with urllib3 indefinitely.
B. Then set the Host header rather than only signing it. Once the value is computed, write it onto the prepared request. Verified: the wire header is byte-identical either way, so this changes nothing about what is transmitted β but signature and wire can no longer diverge, because they become the same string by construction. This is what makes A's sync risk stop mattering, and it closes the whole class of bug rather than the three instances currently known.
The caveat on B is redirects: requests copies headers when rebuilding a redirected request, so an explicit Host would follow to the new target. SigV4 across redirects is already broken for other reasons, so this is likely theoretical, but it should be tested rather than assumed.
C. A flag, only if A+B actually breaks someone. If needed, sign_host_port= (explicit intent) or legacy_strip_port= (names it as legacy), either defaulting to the correct behavior and documented as a compatibility shim rather than a supported mode. Worth keeping in reserve rather than shipping preemptively β every flag here is a permanently supported second code path.
This is what blocked the fix, so it deserves scrutiny β and from first principles the report looks inverted.
The setup in #65 was a tunnel from localhost:9200 to a managed OpenSearch endpoint on 443. Through a tunnel the server receives Host: localhost:9200 on the wire regardless of what gets signed, and SigV4 verification recomputes from the received headers. So signing host:localhost:9200 (post-fix) should have matched, and signing host:localhost (pre-fix) should have failed β the opposite of what was reported. Phillip Berndt reached the same read in #65 at the time.
One new piece of evidence, against real AWS S3 (read-only ListBuckets, us-east-1):
That last row is the informative one: S3 does not check that Host matches its own hostname. It only checks that the signature matches the Host actually sent. Any self-consistent Host is accepted. That is exactly the invariant A+B delivers, so the fix is safe against real AWS β and it is consistent with #65 having been misdiagnosed, since a self-consistent host:localhost:9200 should have been accepted.
Not proof: that was OpenSearch through a tunnel, not S3 directly. A SignedHeaders line from a failing request would settle it. A+B makes it moot either way, since it signs exactly what is sent.
Test approach: a wire-truth oracle
The reason a wrong expectation got committed in #68 is that each case was hand-written, so someone had to decide the right answer per case, and nothing cross-checked them.
The alternative is to not hardcode answers at all β derive the expected value by asking http.client what it would put on the wire for that URL, then assert the signature matches:
This encodes the invariant that actually matters, and it can't go stale if http.client's default-port rule ever changes. Written against HEAD, over the 11 URLs above plus caller-supplied Host and malformed ports:
Ran 83 tests
FAILED (failures=14, skipped=2)
..._using_requests β 7 failures, exactly the β rows above
..._using_httpx β passes all 11
test_requests_and_httpx_sign_the_same_host β the same 7
All 78 pre-existing tests still pass, so this is purely additive. The 14 failures are the enumerated bug list; A+B should turn all of them green along with the RadosGW and MinIO failures above.
On malformed ports: urlparse().port raises ValueError on :notaport and :99999, which looked like it could surface from inside the auth handler. In practice both clients reject those URLs while building the request, before auth ever runs, so the signing path never sees them. Worth a defensive guard, but not a live defect.
Integration testing in CI
Real AWS can't catch this class of bug β every endpoint is on 443, so the non-default-port path is unreachable, and test_live.py hardcodes 'https://' + path_qs with no port. That's a large part of why this survived nine years.
A matrix job running MinIO and RadosGW as containers on non-default ports covers it, with no credentials and no cost. The tests skip when AWS4_TEST_S3_ENDPOINT is unset, so local runs are unaffected.
One trap worth writing down: Ceph's demo entrypoint seds the secret key into a config file, so CEPH_DEMO_SECRET_KEY must not contain /. The standard AWS example key does, and the container exits 1 with sed: -e expression #1, char 44: unknown option to 's'after it looks like it booted successfully.
MinIO is a lighter substitute if Ceph is too heavy:
docker run -d --rm -p 9111:9000 \
-e MINIO_ROOT_USER=demoaccesskey123456 \
-e MINIO_ROOT_PASSWORD=demosecretkey1234567890abcdefghijklmnop \
minio/minio server /data
Generated by Claude (Opus 5). Edited to correct an error in the first version: it said HEAD was wrong on "5 of 11" where the count is 7 β the table was right, the summary sentence was not. Also corrected the malformed-port note, which the first version overstated as a live defect in 8e1417c; both HTTP clients reject those URLs before auth runs. The behavioral claims and every table are reproducible with the scripts above; the #65 analysis is reasoning plus indirect evidence, not a direct measurement of that setup, and remains the part most worth a second pair of eyes.
Update on #81: I removed one part of the fix, so it needs a retest.
The first version did two things: computed the correct host to sign (port kept unless it is the scheme's default), and also wrote that value into the request's Host header. The second part caused a regression. When requests follows a redirect to a different host, it drops Authorization but copies every other header, so the redirect target received the original host instead of its own. The current release doesn't have this problem.
f0623a7 removes the header pinning and adds a regression test for the redirect case. The host computation that fixes this issue is unchanged, and the RadosGW integration job on port 7480 still passes.
@dreznichuk, since the code you tested has changed, could you retest the current ted/issue-79-fix branch on your custom-port and explicit-:443 setups?
I checked the package installed from PyPI, against 1.3.2:
check
1.4.0
1.3.2
RadosGW on 7480 (this issue's server)
pass
SignatureDoesNotMatch
MinIO on 9111
pass
SignatureDoesNotMatch
IPv6 http://[::1]:<port> on both
200
403 SignatureDoesNotMatch
real S3, with and without :443
200
200
The #65 regression that got this fix reverted in 2023 is written up on #65. Reproduced through a tunnel to S3, 1.4.0 works and 1.3.2 fails, the opposite of that report.
Activity
tedder commentedon Jul 27, 2026
It works in httpx but not in requests today. See #34 #63 #65 #68 for the history. I think the assertNotEqual in 8e1417c (from #68) is actually inverted. The real trick is if we can identify the actual errors (and create unit tests for them) and make sure the fix encompasses all of them. I'll put an AI summary next; if you've got time it would be a great project to read through the issues and PRs to make sure it is correct.
tedder commentedon Jul 27, 2026
AI summary of this issue and its history (#34, #63, #65, #68), with everything verified by running it rather than inferred from the threads. All claims below are reproducible with the scripts at the bottom.
Short version
The diagnosis in this issue is correct, and this is a rediscovery of #34 (2017). The fix already exists β
8e1417cfrom #63 β and was reverted inb328b86over the regression reported in #65. It has been stalled there since.The
assertNotEqualin #68'stest_netloc_port_is_kept_for_non_standard_portis indeed inverted. Its httpx twin assertsassertEqualon the same URL, so the suite currently asserts that the two clients must disagree β which is why 78 green tests can't see this bug.Reproduced against real servers
RadosGW (
quay.io/ceph/demo), the same server and port as this issue:requestsSignatureDoesNotMatchhttpxHostheader (the workaround)MinIO on port 9111, same story:
SignatureDoesNotMatchThese are independent implementations of the spec rejecting the signature β not our model of
http.clientdisagreeing with itself. Neither needs AWS credentials, and both start in well under a minute.Where HEAD is wrong
http.client(what requests/urllib3 uses underneath) is ground truth for what goes on the wire:8e1417csignshttps://hhhhhttp://hhhhhttps://h:443hhhhttps://h:8443h:8443hh:8443http://h:80hhhhttp://h:7480h:7480hh:7480https://h:80h:80hh:80http://h:443h:443hh:443https://[::1]:8443[::1]:8443[[::1]:8443https://[::1]:443[::1][[::1]https://[::1][::1][[::1]HEAD is wrong on 7 of 11.
8e1417cis correct on all 11. Row 6 is this issue.Why only
requestsis affectedaws4auth.py:610only runs when'host' not in headers. httpx'sRequest._prepare()populatesHostitself, correctly, port and all β so the library passes it through untouched.requests'PreparedRequestleavesHostunset (urllib3 adds it at send time), so the library computes it, and.netloc.split(':')[0]drops the port unconditionally.Same URL, different signature, depending on which HTTP client is used.
Two bugs in here that aren't in any issue
IPv6 literals are entirely broken, ports aside.
.netloc.split(':')[0]on[::1]returns[β a single open bracket. Evenhttps://[::1]with no port signshost:[.8e1417cfixes this incidentally.Cross-scheme ports.
https://h:80andhttp://h:443are non-default for their scheme, so the port does go on the wire. The one-liner proposed in #34's comments ("check if the port is 80 or 443") gets both wrong;8e1417c's scheme-aware check gets them right. Worth flagging before anyone lands the obvious-looking patch.Fixing it automatically, without a flag
Two mechanisms that compose, and together remove the need for an opt-in:
A. Compute the host the way the client will. This is
8e1417c: strip the port only when it is the scheme's default. Correct on all 11 rows above, and it changes nothing about what is sent over the wire. Its one durable weakness is that it's a reimplementation ofhttp.client's rule, so it has to stay in sync with urllib3 indefinitely.B. Then set the
Hostheader rather than only signing it. Once the value is computed, write it onto the prepared request. Verified: the wire header is byte-identical either way, so this changes nothing about what is transmitted β but signature and wire can no longer diverge, because they become the same string by construction. This is what makes A's sync risk stop mattering, and it closes the whole class of bug rather than the three instances currently known.The caveat on B is redirects: requests copies headers when rebuilding a redirected request, so an explicit
Hostwould follow to the new target. SigV4 across redirects is already broken for other reasons, so this is likely theoretical, but it should be tested rather than assumed.C. A flag, only if A+B actually breaks someone. If needed,
sign_host_port=(explicit intent) orlegacy_strip_port=(names it as legacy), either defaulting to the correct behavior and documented as a compatibility shim rather than a supported mode. Worth keeping in reserve rather than shipping preemptively β every flag here is a permanently supported second code path.The open question: was #65 real?
This is what blocked the fix, so it deserves scrutiny β and from first principles the report looks inverted.
The setup in #65 was a tunnel from
localhost:9200to a managed OpenSearch endpoint on 443. Through a tunnel the server receivesHost: localhost:9200on the wire regardless of what gets signed, and SigV4 verification recomputes from the received headers. So signinghost:localhost:9200(post-fix) should have matched, and signinghost:localhost(pre-fix) should have failed β the opposite of what was reported. Phillip Berndt reached the same read in #65 at the time.One new piece of evidence, against real AWS S3 (read-only
ListBuckets,us-east-1):https://s3.amazonaws.com/(no port)https://s3.amazonaws.com:443/, HEAD behaviorhttps://s3.amazonaws.com:443/, fixed behaviorhttps://s3.amazonaws.com:443/, signinghost:s3.amazonaws.com:443That last row is the informative one: S3 does not check that
Hostmatches its own hostname. It only checks that the signature matches theHostactually sent. Any self-consistentHostis accepted. That is exactly the invariant A+B delivers, so the fix is safe against real AWS β and it is consistent with #65 having been misdiagnosed, since a self-consistenthost:localhost:9200should have been accepted.Not proof: that was OpenSearch through a tunnel, not S3 directly. A
SignedHeadersline from a failing request would settle it. A+B makes it moot either way, since it signs exactly what is sent.Test approach: a wire-truth oracle
The reason a wrong expectation got committed in #68 is that each case was hand-written, so someone had to decide the right answer per case, and nothing cross-checked them.
The alternative is to not hardcode answers at all β derive the expected value by asking
http.clientwhat it would put on the wire for that URL, then assert the signature matches:This encodes the invariant that actually matters, and it can't go stale if
http.client's default-port rule ever changes. Written against HEAD, over the 11 URLs above plus caller-suppliedHostand malformed ports:..._using_requestsβ 7 failures, exactly the β rows above..._using_httpxβ passes all 11test_requests_and_httpx_sign_the_same_hostβ the same 7test_caller_supplied_host_is_used_verbatimβ passestest_malformed_ports_are_rejected_before_signingβ passesAll 78 pre-existing tests still pass, so this is purely additive. The 14 failures are the enumerated bug list; A+B should turn all of them green along with the RadosGW and MinIO failures above.
On malformed ports:
urlparse().portraisesValueErroron:notaportand:99999, which looked like it could surface from inside the auth handler. In practice both clients reject those URLs while building the request, before auth ever runs, so the signing path never sees them. Worth a defensive guard, but not a live defect.Integration testing in CI
Real AWS can't catch this class of bug β every endpoint is on 443, so the non-default-port path is unreachable, and
test_live.pyhardcodes'https://' + path_qswith no port. That's a large part of why this survived nine years.A matrix job running MinIO and RadosGW as containers on non-default ports covers it, with no credentials and no cost. The tests skip when
AWS4_TEST_S3_ENDPOINTis unset, so local runs are unaffected.One trap worth writing down: Ceph's demo entrypoint
seds the secret key into a config file, soCEPH_DEMO_SECRET_KEYmust not contain/. The standard AWS example key does, and the container exits 1 withsed: -e expression #1, char 44: unknown option to 's'after it looks like it booted successfully.Repro
Wire truth vs HEAD vs
8e1417c, no dependenciesEnd-to-end against a local socket on port 7480
Against RadosGW β the server in this issue
docker run -d --rm --name rgw -p 7480:8080 \ -e CEPH_DAEMON=demo -e RGW_NAME=localhost -e MON_IP=127.0.0.1 \ -e CEPH_PUBLIC_NETWORK=0.0.0.0/0 -e CEPH_DEMO_UID=demo \ -e CEPH_DEMO_ACCESS_KEY=demoaccesskey123456 \ -e CEPH_DEMO_SECRET_KEY=demosecretkey1234567890abcdefghijklmnop \ quay.io/ceph/demo:latest-reef # add --platform linux/amd64 on Apple Silicon; ~40s to come upMinIO is a lighter substitute if Ceph is too heavy:
Generated by Claude (Opus 5). Edited to correct an error in the first version: it said HEAD was wrong on "5 of 11" where the count is 7 β the table was right, the summary sentence was not. Also corrected the malformed-port note, which the first version overstated as a live defect in
8e1417c; both HTTP clients reject those URLs before auth runs. The behavioral claims and every table are reproducible with the scripts above; the #65 analysis is reasoning plus indirect evidence, not a direct measurement of that setup, and remains the part most worth a second pair of eyes.fix: sign the Host header that is actually sent
fix: sign the Host header that is actually sent
tedder commentedon Jul 27, 2026
Feel free to test the PR branch of #81 for your use case.
dreznichuk commentedon Jul 28, 2026
Works for me
Tested on custom port and on default https with 443 specified
fix: stop pinning the Host header on the request
tedder commentedon Oct 4, 2026
Update on #81: I removed one part of the fix, so it needs a retest.
The first version did two things: computed the correct host to sign (port kept unless it is the scheme's default), and also wrote that value into the request's
Hostheader. The second part caused a regression. Whenrequestsfollows a redirect to a different host, it dropsAuthorizationbut copies every other header, so the redirect target received the original host instead of its own. The current release doesn't have this problem.f0623a7 removes the header pinning and adds a regression test for the redirect case. The host computation that fixes this issue is unchanged, and the RadosGW integration job on port 7480 still passes.
@dreznichuk, since the code you tested has changed, could you retest the current
ted/issue-79-fixbranch on your custom-port and explicit-:443setups?dreznichuk commentedon Oct 9, 2026
Hi
fresh tests on branch
ted/issue-79-fixcustom port still works
explicit 443 with https - works also
tested on 1.3.2 to ensure that my venv is clean.
still dont work, as expected
tedder commentedon Oct 9, 2026
great- I'll release it.
tedder commentedon Oct 9, 2026
Released in v1.4.0 (PyPI, release notes). Thanks for testing, @dreznichuk.
I checked the package installed from PyPI, against 1.3.2:
SignatureDoesNotMatchSignatureDoesNotMatchhttp://[::1]:<port>on bothSignatureDoesNotMatch:443The #65 regression that got this fix reverted in 2023 is written up on #65. Reproduced through a tunnel to S3, 1.4.0 works and 1.3.2 fails, the opposite of that report.
fix: sign the Host header that is actually sent
fix: stop pinning the Host header on the request