Cloud & Infrastructure Bug Report

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.

Daniel Tran Daniel Tran @fun_dev · April 08, 2025 · 9 min read · 24 Views
Banner: a browser upload to an S3 presigned URL rejected because the signed and sent headers differ

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&amp;X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&amp;X-Amz-Credential=...&amp;X-Amz-Date=20250401T091522Z&amp;X-Amz-Expires=300&amp;X-Amz-SignedHeaders=content-type%3Bhost&amp;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 gives AccessDenied with "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 ContentType out of the command and the browser can send anything. Simpler, but then a client can store an HTML file labelled text/html in your bucket, which matters if you ever serve those objects from your domain.
  • Presigned POST with a policy. createPresignedPost lets you set conditions such as content-length-range and a Content-Type prefix (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 403 responses with error code SignatureDoesNotMatch falling 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 SignatureDoesNotMatch exceeds 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 AllowedHeaders whenever you add a signed header.

References

Add a label

#javascript #aws #s3 #presigned-url #sigv4
Written by
Daniel Tran @fun_dev

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.