AWS S3 error
SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided
"SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method." is S3 answering 403 because the request it received does not match what the presigned URL signed. AWS lists these checks: a system clock out of sync, a URL changed after it was generated, an expired URL, a different content type, the wrong region, and a proxy that edits headers or the query string. With the v3 SDK also check the key pair (a newline after the secret changes every signature), any field that signs a header, such as ContentDisposition or ServerSideEncryption, and the checksum parameters that newer SDK versions add to the URL.
What does a presigned PUT URL from the v3 SDK actually sign?
In a run of @aws-sdk/s3-request-presigner 3.1161.0 with @aws-sdk/client-s3 3.1161.0 on 2026-10-31 (dummy keys, no request sent to AWS), a plain PutObjectCommand produced the query below. X-Amz-SignedHeaders lists only host.
Passing ContentType "image/png" changed nothing: the signature was identical with and without it, because the presigner adds content-type to its unsignable headers (s3-request-presigner/src/presigner.ts). AWS's upload page still lists a matching content type among its checks, and its example is a boto3 script, so send the same Content-Type anyway; with this SDK it is not what breaks the signature.
Other fields do land in the signature. With ACL, Metadata, ServerSideEncryption and ContentDisposition set, X-Amz-SignedHeaders became content-disposition;host;x-amz-server-side-encryption, and x-amz-acl and x-amz-meta-owner moved into the query. AWS says the HTTP method, the headers and the query string must match exactly between generating the URL and using it, so the browser PUT must send each signed header with the signed value.
X-Amz-Algorithm=AWS4-HMAC-SHA256
X-Amz-Content-Sha256=UNSIGNED-PAYLOAD
X-Amz-Credential=AKIDEXAMPLE/20261031/eu-west-1/s3/aws4_request
X-Amz-Date=20261031T005231Z
X-Amz-Expires=300
X-Amz-Signature=ada134d6...
X-Amz-SignedHeaders=host
x-amz-checksum-crc32=AAAAAA==
x-amz-sdk-checksum-algorithm=CRC32
x-id=PutObjectWhat else makes S3 answer SignatureDoesNotMatch, and what can I check locally?
AWS lists these causes for presigned URLs: a system clock that is not synchronized with a time server, a corporate proxy that modifies headers or the query string, a URL changed after it was generated (quote it in curl), an expired URL, the wrong region for the bucket, and a content type or other parameter that differs from the signed one. The key pair is the one cause the error message itself names.
The secret. With the signing date fixed, a secret of "secretexample" produced X-Amz-Signature 9bced68a971c90cf... and "secretexample" plus a newline produced 5121af86b954423e.... S3 computes the signature with the real secret, so a value pasted into a Vercel variable with a newline at the end cannot match.
The session token. With AWS_SESSION_TOKEN set in the environment the URL gained an X-Amz-Security-Token parameter; a client created with an explicit accessKeyId and secretAccessKey did not have one. AWS says a URL made with temporary credentials expires when they do. A report in the vercel/next.js repository (discussion 88351) says Vercel functions have AWS_SESSION_TOKEN taking precedence over the keys you set, and Vercel's docs say its AWS_ variables can appear in a function without you setting them and are not usable as AWS credentials.
The region. X-Amz-Credential carries the signing region (eu-west-1 above). Compare it with the region shown for the bucket in the S3 console.
Does the checksum parameter in the URL break the upload?
The same run showed x-amz-checksum-crc32=AAAAAA== and x-amz-sdk-checksum-algorithm=CRC32 in the query of a plain PutObjectCommand, with no body. AAAAAA== is the CRC32 of zero bytes of data. AWS documents the cause: its SDKs now calculate a checksum for each upload by default, with request_checksum_calculation set to WHEN_SUPPORTED, and JavaScript v3 uses CRC32. An AWS maintainer on aws-sdk-js-v3 issue 6994 ties the new defaults of v3.729.0 to 403 responses on presigned URLs, and names requestChecksumCalculation (PUT) and responseChecksumValidation (GET) set to WHEN_REQUIRED as the opt-out, while calling disabling them not recommended in general.
With requestChecksumCalculation set to WHEN_REQUIRED on the client, the run produced a URL with no checksum parameters and the same X-Amz-SignedHeaders. We did not send either URL to S3, so test both against your bucket. A Fly.io thread reports the same fix for SignatureDoesNotMatch on Tigris, an S3-compatible store, which is not AWS.
default SignedHeaders=host | params: x-amz-checksum-crc32, x-amz-sdk-checksum-algorithm
WHEN_REQUIRED SignedHeaders=host | params: (no checksum params)Checklist
How to fix it, then check the fix
- 01Print the parts of the URL that matter, not the whole URL, because AWS calls presigned URLs bearer tokens:
const u = new URL(presignedUrl) for (const key of ["X-Amz-SignedHeaders", "X-Amz-Credential", "X-Amz-Date", "X-Amz-Expires"]) console.log(key, u.searchParams.get(key)) - 02Send the PUT with the URL string unchanged and the method PUT. For every name in X-Amz-SignedHeaders other than host, send that header with the value you signed:
await fetch(uploadUrl, { method: "PUT", body: file, headers: { "Content-Type": file.type } }) - 03Re-enter the access key and secret in Vercel with no space or newline at either end, delete AWS_SESSION_TOKEN unless you use temporary credentials, and pass the keys to the client under your own names so Vercel's AWS_ variables are not picked up:
const s3 = new S3Client({ region: process.env.S3_REGION, credentials: { accessKeyId: process.env.S3_ACCESS_KEY_ID, secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, }, }) - 04Remove optional fields from PutObjectCommand (ContentDisposition, ServerSideEncryption, ACL, Metadata) until the upload works, then add each back together with the matching header on the PUT.
- 05If it started after an upgrade of @aws-sdk/client-s3, build the presigning client with requestChecksumCalculation set to WHEN_REQUIRED and compare the two URLs:
new S3Client({ region, credentials, requestChecksumCalculation: "WHEN_REQUIRED" }) - 06Rule out time and the network, as AWS suggests: sync the system clock, request the URL from curl with the URL in double quotes, and try without a proxy:
curl -X PUT -T ./photo.png -H "Content-Type: image/png" "PASTE_URL"
Related
Related guides
Sources
Official docs behind this checklist
- AWS, Uploading objects with presigned URLs (SignatureDoesNotMatch checks)
- AWS, Download and upload objects with presigned URLs (FAQ, expiry)
- AWS SDKs and Tools, Data integrity protections for Amazon S3
- aws-sdk-js-v3, s3-request-presigner/src/presigner.ts
- aws-sdk-js-v3, issue 6927 (exact error text)
- aws-sdk-js-v3, issue 6994 (v3.729.0 checksum defaults)
- Vercel, Reserved environment variables
- vercel/next.js, discussion 88351 (report)
- Fly.io community, SignatureDoesNotMatch with a pre-signed URL (Tigris)
Browse all launch guides · Read resources · How to make and check your next change
FAQ
FAQ for builders before launch
Does the Content-Type have to match the one used to sign the URL?
AWS lists it among its checks. In the run above, @aws-sdk/s3-request-presigner 3.1161.0 did not put content-type in X-Amz-SignedHeaders, so it did not change the signature. Send the same Content-Type that you passed to PutObjectCommand anyway.
Why does my presigned URL contain X-Amz-Security-Token?
The credentials that signed it include a session token, for example because AWS_SESSION_TOKEN is set. AWS says a URL made with temporary credentials expires when those credentials do, even if you asked for a longer time.
Is a CORS error the same as SignatureDoesNotMatch?
No. A CORS error is the browser blocking the response because the bucket has no matching CORS rule. SignatureDoesNotMatch is an S3 403 about the signature, and you read it in the response body. Fix CORS first if the console shows both.