The signature you sent does not match the one AWS computed from the same request. Because SigV4 signs the method, path, query string, several headers, the payload hash, the timestamp, the region and the service, anything that alters one of those on the way produces this.
It is rarely a wrong secret key, though that is where people look first.
Check the clock
timedatectl status | head -5
date -u
SigV4 includes X-Amz-Date, and AWS rejects requests more than 15 minutes from its own clock. No part of the error mentions time, so this is easy to miss.
sudo systemctl restart systemd-timesyncd
sudo chronyc makestep # if using chrony
Laptops resumed from long suspends, VMs restored from snapshots, and containers on a host with a drifting clock are the usual sources. Inside a container the clock comes from the host, so fix it there.
Look for whitespace in the key
aws configure get aws_secret_access_key | od -c | tail -3
0000060 / 9 K D e x a m p l e K E Y \n
A trailing space or newline is invisible in an editor and breaks the HMAC. Common sources:
- Pasting from a terminal that wrapped the line
- A CI secret saved with a trailing newline
- YAML without quotes, where trailing spaces survive
export AWS_SECRET_ACCESS_KEY=$(cat key.txt)where the file ends with a newline
export AWS_SECRET_ACCESS_KEY="$(tr -d '[:space:]' < key.txt)"
Special characters matter too. A secret key containing / or + inside an unquoted shell variable, or embedded in a URL, gets mangled.
Confirm the region
aws s3api get-bucket-location --bucket acme-assets
{ "LocationConstraint": "eu-west-1" }
A null result means us-east-1, which is a quirk worth knowing.
Signing for the wrong region gives a more specific message:
AuthorizationHeaderMalformed: the region 'us-east-1' is wrong; expecting 'eu-west-1'
That variant is the friendly one, because it names both. Set the region explicitly:
aws s3 ls s3://acme-assets --region eu-west-1
A proxy rewriting the request
Any middlebox that modifies signed headers breaks the signature:
- A corporate proxy adding or normalising headers
- A TLS-inspecting proxy
- An API gateway or WAF in front of an S3-compatible endpoint
- A load balancer rewriting
Host
Host is always signed, so anything that changes it invalidates the signature. Content-Type is frequently signed too.
env | grep -i proxy
curl -sv https://acme-assets.s3.eu-west-1.amazonaws.com/ 2>&1 | grep -i "^< "
Test with the proxy bypassed:
no_proxy="s3.eu-west-1.amazonaws.com" aws s3 ls s3://acme-assets
Presigned URLs
aws s3 presign s3://acme-assets/report.pdf --expires-in 3600
Things that break a presigned URL:
Expiry. Maximum is 7 days for SigV4, and 12 hours when signed with a role's temporary credentials, because the URL cannot outlive the session that created it.
The credential expired. A presigned URL created with a role session dies when that session does, regardless of the expiry you asked for.
Extra query parameters. Anything appended after signing, including analytics parameters added by a link shortener or an email tracker, invalidates the signature.
Re-encoding. A URL passed through something that re-encodes %2F or + in the signature breaks it. Presigned URLs should be treated as opaque.
Uploads with a changed body
For PutObject, the payload hash is signed. Anything that alters the body in transit breaks it:
- A proxy compressing or decompressing
- Reading from a stream that was partially consumed before signing
- A
Content-Lengththat disagrees with what is sent
For large uploads, use multipart, which signs each part independently and is more robust as well as resumable.
S3-compatible endpoints
MinIO, Cloudflare R2, Backblaze B2 and similar implement SigV4 with differences. Two settings usually matter:
aws configure set default.s3.addressing_style path
aws --endpoint-url https://minio.internal:9000 s3 ls
Virtual-hosted addressing puts the bucket in the hostname, which changes the signed Host header. Many self-hosted endpoints only support path style.
Some also require a specific region string, often us-east-1, regardless of where they run.
A checklist
- Check the clock.
date -uagainst real time. 15 minute tolerance. od -cthe secret key for a trailing newline or space.get-bucket-locationand sign for the bucket's real region.AuthorizationHeaderMalformednames both regions; that is the easy variant.- Check for a proxy.
Hostis always signed. - Presigned URL → check expiry, and whether the signing session has ended.
- Never append query parameters to a presigned URL.
- S3-compatible endpoint → try
addressing_style = path.
Frequently Asked Questions
Why does a valid secret key produce SignatureDoesNotMatch?
Because the signature covers far more than the key: the HTTP method, the canonical path and query string, several headers including Host, a hash of the payload, the timestamp, the region and the service. Anything that differs between what you signed and what AWS received produces a mismatch. In practice the most common causes are a clock more than 15 minutes out, invisible whitespace on the end of the key, the wrong region, and a proxy rewriting headers in transit.
How much clock drift does AWS tolerate?
Fifteen minutes in either direction. Beyond that the request is rejected, and nothing in the error message mentions time, which makes it a frustrating thing to chase. Check with date -u against a known-good source and restart your NTP service. Machines that suspend for long periods, virtual machines restored from snapshots, and containers inheriting a drifted host clock are the usual offenders; inside a container you must fix the host.
Why does my presigned URL stop working before it expires?
Most likely because it was signed with temporary credentials from an assumed role, and a presigned URL cannot outlive the session that created it. A URL requested with a twelve hour expiry but signed by a one hour role session dies after an hour. The other common cause is something appending query parameters after signing, such as a link shortener or an email tracker adding analytics, since every query parameter is part of the signed request.
What does AuthorizationHeaderMalformed mean?
It is the region variant of the same problem, and it is the most helpful version because it names both the region you signed for and the one the bucket is in. SigV4 binds a signature to a specific region, so signing for us-east-1 against a bucket in eu-west-1 cannot validate. Run aws s3api get-bucket-location to get the real region, remembering that a null result means us-east-1.
Why does this happen against MinIO or R2 but not AWS?
Because S3-compatible endpoints implement SigV4 with small differences, most often around addressing style. AWS defaults to virtual-hosted addressing, which puts the bucket name in the hostname and therefore in the signed Host header, while many self-hosted endpoints only support path-style addressing. Set aws configure set default.s3.addressing_style path. Some implementations also require a particular region string, commonly us-east-1, regardless of where they actually run.