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:
file.typeis''for extensions the OS doesn't recognise, so the browser sends nothing while you signedapplication/octet-stream.- You signed
image/jpegbut the client sentimage/jpg(or your presign endpoint took the type from the filename extension and the browser took it from the OS mime registry). - You switched from
fetchtoaxios, which helpfully rewrites the header forFormDataand appends aboundary=.... - A corporate proxy or CDN normalises
text/csvtotext/csv; charset=utf-8.
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
- You must enforce content type or max size at upload time. Unsigned headers are unvalidated, so a client can send anything. Use presigned POST with
content-length-rangeandstarts-withconditions, or upload through your own API for small files. - Multipart uploads of large files. Presigning
UploadPartper part is a different flow; a single PUT caps at 5 GB and gives no resumability. Reach forCreateMultipartUpload+ per-part presigned URLs. - Server-side encryption with SSE-C or a specific KMS key. Those require signed
x-amz-server-side-encryption*headers; here you must sign them and mirror them exactly on the client. Set bucket-default encryption instead if you can. - You want an audit trail of who uploaded what. Don't lean on signed
x-amz-meta-*; derive it from the key prefix you control (uploads/{userId}/{uuid}).
Gotchas
- SDK v3 checksum headers (2025+). Newer
@aws-sdk/client-s3defaultsrequestChecksumCalculationtoWHEN_SUPPORTED, which signsx-amz-checksum-crc32andx-amz-sdk-checksum-algorithminto presigned URLs. Any plain HTTP client that doesn't send them getsSignatureDoesNotMatch. SetWHEN_REQUIREDon the presigning client. - HTML-escaped URLs. If the URL passes through a template that escapes
&to&, the query string differs and the signature fails. Same for a URL that getsencodeURIComponent'd a second time —%2Fbecoming%252F. - Keys with spaces or
+. The presigner encodes the path its own way; if your client rebuilds the URL from parts it will likely encode differently. Never reconstruct a presigned URL — pass the full string through untouched. FormDataon a presigned PUT.fetch(url, { method: 'PUT', body: formData })uploads the multipart envelope as the object body and changes Content-Type. For presigned PUT, the body is the rawFile/Blob.- Proxies rewriting Host. A CDN or reverse proxy in front of S3 that changes the
Hostheader breaks the always-signedhostentry. Upload straight to the S3 endpoint the presigner produced. - Expired ≠ mismatched.
Request has expiredmeans clock/TTL;SignatureDoesNotMatchmeans canonical-request drift. Don't debug one as the other. And if a role's temporary credentials expire before the URL does, you getAccessDenied, not either of these. - Read
StringToSignin the error body. S3 tells you exactly what it hashed. Diff it against what your SDK signed; the mismatched line is right there.
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.