S3 presigned PUT upload fails with SignatureDoesNotMatch from the browser
Receipt uploads to S3 failed with 403 SignatureDoesNotMatch for about 4 percent of users, mostly iPhone photos uploaded from Windows. The URL was signed with one Content-Type and the browser sent another. Reading the CanonicalRequest in the error pinpointed it.
Our expense app lets employees photograph a receipt and upload it straight to S3 with a presigned URL, so files never pass through our API servers. After a redesign of the upload widget, support tickets started arriving: "upload failed, please try again", and retrying did not help. Our client error tracking showed about 4 percent of uploads failing with HTTP 403.
The response body from S3 was the XML error:
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>SignatureDoesNotMatch</Code>
<Message>The request signature we calculated does not match the signature you provided. Check your key and signing method.</Message>
<AWSAccessKeyId>AKIAIOSFODNN7EXAMPLE</AWSAccessKeyId>
<StringToSign>AWS4-HMAC-SHA256
20250401T091522Z
20250401/eu-central-1/s3/aws4_request
6b0f...</StringToSign>
<SignatureProvided>3c9e...</SignatureProvided>
<CanonicalRequest>PUT
/receipts/u_4821/2025-04-01/7f3a9c.heic
X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=...&X-Amz-Date=20250401T091522Z&X-Amz-Expires=300&X-Amz-SignedHeaders=content-type%3Bhost&x-id=PutObject
content-type:
host:expense-receipts-prod.s3.eu-central-1.amazonaws.com
content-type;host
UNSIGNED-PAYLOAD</CanonicalRequest>
<RequestId>4Q2W...</RequestId>
</Error>
Two details stood out: the file was a .heic, and the content-type: line in S3's canonical request
was empty.
Reproducing it
Grouping failures by device and browser made the pattern obvious: 91 percent of failures were HEIC
photos uploaded from Chrome or Edge on Windows, typically synced from an iPhone through a cloud
drive. The rest were odd file types such as .jfif. On Windows we could reproduce it every time:
const input = document.querySelector('input[type=file]');
console.log(input.files[0].name, JSON.stringify(input.files[0].type));
// "IMG_2291.HEIC" ""
The browser did not know a MIME type for HEIC on that system, so File.type was an empty string.
What we ruled out
- Wrong credentials or region. Uploads of JPEG and PDF files with the same code path, same bucket and same signing role succeeded. A wrong region produces a different error naming the expected region.
- Clock skew. Skew gives
RequestTimeTooSkewed, and an expired URL givesAccessDeniedwith "Request has expired". Neither matched. - CORS. A CORS failure shows up in the browser console as a blocked request with no readable response. Here we had a real 403 response body from S3.
- The SDK checksum change. AWS SDK for JavaScript v3.729.0 started adding integrity checksums to S3 requests by default. We checked our presigned URLs; they carried no checksum parameters, so that was not our problem this time.
Root cause
The API endpoint that issued presigned URLs took the content type from the client and fell back to a default when it was empty:
const contentType = body.contentType || 'application/octet-stream';
const url = await getSignedUrl(
s3,
new PutObjectCommand({ Bucket: BUCKET, Key: key, ContentType: contentType }),
{ expiresIn: 300 },
);
So the URL was signed with content-type: application/octet-stream (note X-Amz-SignedHeaders= content-type;host). The browser then uploaded with fetch(url, { method: 'PUT', body: file }). When
the body is a File with an empty type, the browser sends no Content-Type header at all. S3
rebuilt the canonical request with an empty content type, computed a different signature, and
rejected the upload. The old widget had always set the header explicitly; the redesign dropped it.
What is happening under the hood
A presigned URL is a SigV4 signature carried in the query string. The signature covers a canonical
request: method, path, query parameters, and the values of every header listed in
X-Amz-SignedHeaders. When S3 receives the upload, it rebuilds the canonical request from what was
actually sent and recomputes the signature. Any difference, even one header value, gives
SignatureDoesNotMatch.
When you pass ContentType to PutObjectCommand before presigning, the SDK signs the
content-type header. The uploader must then send exactly that value. If you do not pass it, only
host is signed and the browser may send any content type, which S3 stores as the object's
metadata.
The CanonicalRequest in S3's error response is the fastest debugging tool. Compare it to what your code signed: the line that differs is the bug.
Environment
| Component | Version / value |
|---|---|
| Backend | Node.js 22.14, @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner 3.758 |
| Bucket | eu-central-1, Block Public Access on, SSE-S3 |
| Frontend | React 19 SPA, fetch PUT with File body |
| Affected clients | Chrome and Edge on Windows with HEIC files; some .jfif files |
| URL lifetime | 300 seconds |
Minimal reproduction
// server
const url = await getSignedUrl(
s3,
new PutObjectCommand({ Bucket: 'example-bucket', Key: 'test.bin', ContentType: 'application/octet-stream' }),
{ expiresIn: 300 },
);
// browser: a Blob with no type means fetch sends no Content-Type header
await fetch(url, { method: 'PUT', body: new Blob(['hello']) });
// 403 SignatureDoesNotMatch
Who is affected
Any client uploading to a presigned URL whose signed headers differ from what it sends. Common
variants: Content-Type mismatch (empty File.type, a library adding ; charset=utf-8, axios
defaulting to application/x-www-form-urlencoded or application/json for some bodies), signing
ContentMD5 or ACL or metadata (x-amz-meta-*) and not sending them, region or endpoint mismatch
between signer and bucket, and object keys with characters that get encoded differently.
The fix
We made the server the single source of truth for the content type and returned it to the client, so both sides use the same value:
// server: POST /uploads/presign
const ALLOWED = new Map([
['jpg', 'image/jpeg'], ['jpeg', 'image/jpeg'], ['png', 'image/png'],
['heic', 'image/heic'], ['pdf', 'application/pdf'],
]);
export async function presign(userId: string, fileName: string, size: number) {
const ext = fileName.split('.').pop()?.toLowerCase() ?? '';
const contentType = ALLOWED.get(ext);
if (!contentType) throw new HttpError(415, 'Unsupported file type');
if (size > 15 * 1024 * 1024) throw new HttpError(413, 'File too large');
const key = `receipts/${userId}/${new Date().toISOString().slice(0, 10)}/${crypto.randomUUID()}.${ext}`;
const url = await getSignedUrl(
s3,
new PutObjectCommand({ Bucket: BUCKET, Key: key, ContentType: contentType }),
{ expiresIn: 300 },
);
return { url, key, contentType };
}
// client
-const { url } = await api.presign({ fileName: file.name, contentType: file.type });
-await fetch(url, { method: 'PUT', body: file });
+const { url, contentType } = await api.presign({ fileName: file.name, size: file.size });
+const res = await fetch(url, {
+ method: 'PUT',
+ body: file,
+ headers: { 'Content-Type': contentType },
+});
+if (!res.ok) throw new Error(`upload failed: ${res.status} ${await res.text()}`);
The bucket CORS rule already allowed the header, but it is worth checking, since a missing entry blocks the request before S3 even evaluates the signature:
[
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["content-type"],
"MaxAgeSeconds": 3600
}
]
Alternatives and trade-offs
- Do not sign the content type. Leave
ContentTypeout of the command and the browser can send anything. Simpler, but then a client can store an HTML file labelledtext/htmlin your bucket, which matters if you ever serve those objects from your domain. - Presigned POST with a policy.
createPresignedPostlets you set conditions such ascontent-length-rangeand aContent-Typeprefix (starts-with), which enforces file size at S3. It uses a multipart form upload instead of a raw PUT. - If your presigned URLs include checksum parameters after an SDK upgrade, either send the
matching checksum header from the client or configure the S3 client with
requestChecksumCalculation: 'WHEN_REQUIRED'for the presigning client.
How to test the fix
A test that signs a URL and uploads to a local S3-compatible server catches header mismatches, and a quick manual check with curl mirrors what the browser sends:
URL=$(curl -s -X POST https://api.example.com/uploads/presign \
-H "Authorization: Bearer $TEST_TOKEN" -H 'Content-Type: application/json' \
-d '{"fileName":"IMG_2291.HEIC","size":2048000}' | jq -r .url)
# correct header: expect 200
curl -s -o /dev/null -w '%{http_code}\n' -X PUT -H 'Content-Type: image/heic' --data-binary @IMG_2291.HEIC "$URL"
# missing header: expect 403 SignatureDoesNotMatch
curl -s -X PUT -H 'Content-Type:' --data-binary @IMG_2291.HEIC "$URL" | grep -o '<Code>[^<]*'
We also added an end-to-end test that uploads a Blob with an empty type from the browser, which is
exactly the case the old code failed.
Confirming in production
- Upload failure rate dropped from 4.1 percent to 0.06 percent within a day; the remainder were network aborts on mobile connections.
- S3 server access logs for the bucket showed
403responses with error codeSignatureDoesNotMatchfalling from about 380 per day to zero. - HEIC uploads from Windows, previously failing almost always, succeeded in 2,914 of 2,916 attempts in the first week.
Regression guard
- The browser E2E suite uploads files with empty, unusual and uppercase extensions.
- The client logs S3's
<Code>value on any failed upload, so the next signature problem is identified from telemetry rather than tickets. - A CloudWatch metric filter on S3 access logs alerts if
SignatureDoesNotMatchexceeds 20 per hour.
Security implications
The fix tightens security. The server now decides the content type from an allowlist, enforces a
size limit before signing, generates the object key itself (so clients cannot overwrite other users'
files), and keeps URLs valid for only five minutes. Presigned URLs are bearer credentials until they
expire: never log full URLs, and sign with a role that can only s3:PutObject under the receipts/
prefix.
Prevention checklist
- Decide the content type on the server and send the exact same value from the client.
- Read the CanonicalRequest in the error response and diff it against what you signed.
- Do not trust
File.type; it can be empty or wrong. - Keep presigned URLs short-lived and scoped to server-generated keys.
- Check CORS
AllowedHeaderswhenever you add a signed header.
References
- Amazon S3 User Guide: Uploading objects with presigned URLs — https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html
- Amazon S3 API Reference: Authenticating requests with query parameters (SigV4) — https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html
- Amazon S3 User Guide: Using cross-origin resource sharing (CORS) — https://docs.aws.amazon.com/AmazonS3/latest/userguide/cors.html
- AWS SDK for JavaScript v3: s3-request-presigner — https://github.com/aws/aws-sdk-js-v3/tree/main/packages/s3-request-presigner
- AWS SDK for JavaScript v3: Default data integrity protections announcement — https://github.com/aws/aws-sdk-js-v3/issues/6810
- Fetch Standard: extracting a body and its Content-Type — https://fetch.spec.whatwg.org/#concept-bodyinit-extract
Add a label
Embed this post
<iframe src="https://hibug.me/s3-presigned-url-signaturedoesnotmatch" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.
Related Stories
Detecting and preventing infrastructure drift with Terraform
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.