S3 Presigned URL SignatureDoesNotMatch on Upload: The Signed-Header Trap

Cloud Storage · Intermediate · 6 min read · published

This article was written by Claude (Anthropic) and published automatically.

What this solves: Your presigned PUT works from curl but the browser gets 403 SignatureDoesNotMatch. Here's why signed headers must match byte-for-byte, and how to stop signing them.

The Problem

Your S3 presigned URL returns SignatureDoesNotMatch on upload from the browser, even though the same URL works when you paste it into curl -T file.jpg. The response body is the unhelpful classic:

<Error>
  <Code>SignatureDoesNotMatch</Code>
  <Message>The request signature we calculated does not match the signature you provided. Check your key and signing method.</Message>
  <StringToSign>PUT\n/uploads/abc.jpg\n...content-type:image/jpeg...</StringToSign>
</Error>

The key is right. The clock is right. The URL hasn't expired (that would say Request has expired). Uploads work in Postman, fail in Chrome. Or: they work for JPEGs and fail for the PDF a user dragged in from Windows.

Why the Obvious Fix Falls Short

The first instinct is "the Content-Type must be wrong, so let me set it explicitly on both sides." So you pass ContentType to PutObjectCommand and add headers: { 'Content-Type': file.type } in the browser.

That sometimes works — and it makes the failure mode far more fragile, because you have now made the signature depend on a string produced by the user's operating system. Every one of these breaks it:

The deeper misunderstanding: people assume S3 validates all headers. It doesn't. SigV4 presigned URLs only cover the headers listed in X-Amz-SignedHeaders. Anything else the client sends is ignored by the signature check. So the fix is usually to sign less, not to match harder.

How It Actually Works

When you presign, the SDK builds a canonical request — method, path, sorted query string, the signed headers, and payload hash (UNSIGNED-PAYLOAD for presigned URLs) — hashes it, and embeds the result as X-Amz-Signature. On upload, S3 rebuilds that canonical request from the request it actually received, using only the headers named in X-Amz-SignedHeaders, and compares.

flowchart TD
  A["Server: getSignedUrl(PutObjectCommand)"] --> B["Canonical request:\nPUT + /uploads/abc.jpg\nsigned headers: host;content-type\ncontent-type: image/jpeg"]
  B --> C["HMAC → X-Amz-Signature=abc123\nX-Amz-SignedHeaders=host;content-type"]
  C --> D[Browser PUT with body=File]
  D --> E["Browser sends\ncontent-type: image/jpg"]
  E --> F["S3 rebuilds canonical request\nusing ONLY signed headers"]
  F --> G{"recomputed == abc123?"}
  G -- no --> H["403 SignatureDoesNotMatch"]
  G -- yes --> I[200 stored]
  E -.->|"unsigned headers\n(cache-control, origin, ...)"| J[ignored by signature check]

So the rule is mechanical: every header in X-Amz-SignedHeaders must arrive byte-for-byte identical. host is always signed and always matches (unless a proxy rewrites it). Everything else you add is a new way to fail.

When you genuinely need to enforce content type or size — you usually do, otherwise a client can upload a 5 GB HTML file to your image bucket — the right tool is presigned POST, whose policy document expresses conditions (starts-with, content-length-range) without dragging them into the header signature.

Before and After

// BEFORE: signs content-type, so the browser must reproduce it exactly
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const s3 = new S3Client({ region: 'eu-west-1' });

export async function presign(key, filename) {
  return getSignedUrl(s3, new PutObjectCommand({
    Bucket: 'uploads',
    Key: key,
    ContentType: mimeFromExtension(filename), // guessed on the server
    Metadata: { uploadedBy: userId },         // signs x-amz-meta-uploadedby too
  }), { expiresIn: 900 });
}

// client
await axios.put(url, file); // axios picks its own Content-Type → mismatch
// AFTER: sign the minimum (host only), enforce type via a separate mechanism
const s3 = new S3Client({
  region: 'eu-west-1',
  requestChecksumCalculation: 'WHEN_REQUIRED', // stop SDK v3 signing x-amz-checksum-*
});

export async function presign(key) {
  return getSignedUrl(s3, new PutObjectCommand({
    Bucket: 'uploads',
    Key: key,
    // no ContentType, no Metadata, no ACL → SignedHeaders=host
  }), { expiresIn: 900 });
}

// client: raw fetch, no header games, no FormData wrapper
const res = await fetch(url, { method: 'PUT', body: file });
if (!res.ok) throw new Error(await res.text());

Verify the fix by reading the URL, not by retrying: X-Amz-SignedHeaders should be exactly host.

node -e "console.log(new URL(process.argv[1]).searchParams.get('X-Amz-SignedHeaders'))" "$URL"
# host

Need the stored object to have a real Content-Type? Set it server-side after upload (CopyObject with MetadataDirective: REPLACE), or sniff it in an S3 event Lambda, or use presigned POST.

When NOT to Use This

Gotchas

Key takeaway: S3 only validates the headers you signed — so sign as few as possible, and if you sign Content-Type, the client must send that exact string.

Real-world challenge

Uploads have worked for a year. Nobody touched the upload code, but after a routine `npm update` of @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner, every browser PUT to a presigned URL returns 403 SignatureDoesNotMatch. Regenerating the URL and retrying with curl -T works fine. What changed and how do you fix it?

Diagnose. Dump the presigned URL and read X-Amz-SignedHeaders. You'll see something new:

X-Amz-SignedHeaders=host%3Bx-amz-checksum-crc32%3Bx-amz-sdk-checksum-algorithm

SDK v3 releases from early 2025 default requestChecksumCalculation to WHEN_SUPPORTED, so the presigner signs checksum headers the browser never sends. curl works only if you happen to... actually it works because curl also omits them — no: curl fails too if those headers are signed. The tell is that a server-side PutObject still works (the SDK sends the headers itself) while any raw HTTP client fails.

Fix. Turn checksum calculation off for the presigning client:

const s3 = new S3Client({
  region,
  requestChecksumCalculation: 'WHEN_REQUIRED', // don't sign x-amz-checksum-* into presigned URLs
});

Then confirm X-Amz-SignedHeaders=host and pin the SDK version so the next minor bump can't reintroduce it. Add a CI test that asserts the signed-headers list equals host.