Encrypt Online
Theme

Certificates & Site Ops

Fix S3 Presigned URL SignatureDoesNotMatch Errors

Debug S3 presigned URL errors by checking the HTTP method, signed headers, URL encoding, region, and expiry. Includes Python and curl examples.

Encrypt Online Editorial Team6 min read
Encrypt Online guide cover on a sand background with the headline Fix S3 presigned URLs. The established AWS SigV4 cloud symbol represents the signed request.

Start with the HTTP method when S3 returns SignatureDoesNotMatch. Opening an upload URL in a browser sends GET, but the URL may have been signed for PUT. The request must use the signed method, host, path, query parameters, and headers.

Paste the URL into the S3 Presigned URL Debugger and choose the method your application sends. The URL contains the signing fields, but it doesn't say whether it was signed for GET or PUT; you'll need that from the code that created it.

S3 error codes and failed requests

Read the error in the response body or your browser's Network panel. Several different problems return HTTP 403:

What you see What to inspect first
SignatureDoesNotMatch Method, signed headers, and changes to the URL
ExpiredToken The credentials used to create the URL
A message saying the request has expired Signing time plus the requested lifetime
AccessDenied The signing identity's permissions and applicable policies
A failed browser OPTIONS request The bucket's CORS rule for that origin, method, and headers

Record the error code, method, and header names before retrying. See AWS's presigned URL failure cases for the credential and policy checks behind these errors.

GET, PUT, and HEAD produce different signatures

GET downloads an object, PUT uploads one, and HEAD reads its metadata. Switching methods changes the canonical request that S3 uses to check the signature.

curl -I sends HEAD, so it can fail with a URL that works for GET. To read response headers while retaining GET, set PRESIGNED_URL to the complete URL and run:

Shell
curl --silent --show-error --dump-header - --output /dev/null "$PRESIGNED_URL"

This downloads the object and discards its body, so use a small test object. Keep the URL quoted: otherwise the shell can treat its & characters as command separators.

Tip: Keep one small test object and generate a fresh URL for each experiment. Change one request detail at a time so you can identify the cause.

Content-Type and other signed headers

Decode X-Amz-SignedHeaders. A value such as content-type;host means both headers participate in the signature. The URL contains the header names, but it does not contain the original Content-Type value. Retrieve that value from the code that creates the URL.

If the signer used application/pdf, send that exact media type. application/octet-stream and application/pdf; charset=utf-8 are different values. Header names are normalized to lowercase during signing; values still matter. To recalculate the signature, you'll also need the original signing secret and complete request details. AWS's query signing rules describe those inputs.

For a URL signed for a PDF upload, this sends the file as the request body:

Shell
curl --request PUT --upload-file ./sample.pdf \
  --header 'Content-Type: application/pdf' \
  "$PRESIGNED_URL"

Use a disposable object key for this test: uploading replaces an existing object at that key. In a browser, send the file itself for a presigned PutObject URL. FormData wraps it in a multipart body and changes the media type. Presigned POST forms use a separate workflow. See AWS's presigned upload example for the method and content type.

When an upload is signed with server-side encryption headers, send those headers too. An application that returns only a URL may need to return the required upload headers alongside it. The AWS Developer Tools Blog covers this presigner integration detail.

URL encoding, object keys, and region

Use the URL returned by the signer. Avoid decoding and rebuilding it before the request. That can change spaces, plus signs, repeated query parameters, or percent escapes. A copied HTML attribute may contain literal & instead of &; obtain the original URL value from your application.

Keep the signed S3 host; replacing it with a CDN domain changes the signature input. Add download filename parameters before signing, too. Query order is canonicalized, but parameter values must stay the same.

For S3, an object key containing repeated slashes must retain them. Generic path cleanup can select a different key. AWS describes this S3-specific rule in its canonical request reference.

Read the region and service from the decoded credential scope:

Text
EXAMPLE_ACCESS_KEY/20261002/eu-west-1/s3/aws4_request

Compare that region with the bucket's actual region. Regenerate with the correct S3 client configuration if they differ; editing the scope in the existing URL changes signed data.

Temporary credentials can expire before the URL

The URL's requested deadline is X-Amz-Date + X-Amz-Expires seconds. For example, 20261002T120000Z plus 900 seconds gives 2026-10-02 12:15:00 UTC.

Temporary credentials can expire sooner. A Lambda or assumed-role session may end while the URL still appears to have time remaining. Check the credential provider's expiration; you can't reliably read it from the session token in the URL. If the credentials have expired, refresh them and generate a new URL.

Bucket policy can also shorten access with s3:signatureAge, measured in milliseconds. Check that setting if the credentials are still active and the URL fails before its calculated deadline. AWS explains these credential and policy limits.

Generate a download URL with Boto3

This Boto3 example creates a ten-minute download URL you can compare with your application's output. Install Boto3 and configure your normal AWS credential provider, then replace the bucket, region, and key with a small test object you control.

PYTHON
import boto3
from botocore.config import Config

s3 = boto3.client(
    "s3", region_name="eu-west-1",
    config=Config(signature_version="s3v4"),
)
download_url = s3.generate_presigned_url(
    "get_object",
    Params={"Bucket": "your-test-bucket", "Key": "debug/sample.txt"},
    ExpiresIn=600,
)
print(download_url)

Pass the result unchanged to the GET command above. If it works, compare its URL and headers with the failing request. Boto3 documents presigning methods and configuration.

For custom signers, compare the canonical request before comparing the final signature. Presigned S3 requests commonly use UNSIGNED-PAYLOAD; the canonical query excludes X-Amz-Signature. Keep any session token in the query. The SigV4 canonical request guide explains the individual signing inputs.

CORS failures in the browser

If the request works with curl but the browser fails, inspect both OPTIONS and the actual GET or PUT. The bucket CORS rule must allow the browser's origin, requested method, and requested headers. An OPTIONS failure can stop the upload before S3 receives it. A browser can also hide a response that lacks suitable CORS headers, so check the actual request status where available. Follow AWS's CORS troubleshooting steps.

AccessDenied on an SSE-KMS object

If S3 returns AccessDenied, check the signing identity's permissions and the applicable policies. Downloading an SSE-KMS object also requires KMS permissions, including kms:Decrypt. The signing identity needs access to that key even when the signature is correct.

Do not add the upload-only x-amz-server-side-encryption header to a GetObject request for SSE-S3 or SSE-KMS data. AWS documents the encryption behavior in the GetObject API reference.