The browser PUTs a 500 MB video against a presigned S3 URL. The first 200 MB go up fine, then the response turns into 403 Forbidden with <Message>Request has expired</Message>. The signed URL had a TTL of five minutes — fine for thumbnails, far too short for a large upload on a slow uplink.
Fastest fix: stop using a single-part PUT for anything over ~100 MB. Switch to an S3 multipart upload with one presigned URL per part, or hand the work to the SDK’s upload manager (@aws-sdk/lib-storage Upload in Node, boto3 upload_file in Python), which chunks, parallelizes, and retries for you.
One trap that bites teams even after they raise the TTL: if you sign the URL with temporary credentials (an IAM role, an EC2 instance profile, or sts:AssumeRole), the URL dies when those credentials expire — no matter how long you set ExpiresIn. Check that case below before you blame the TTL.
First, read the actual error code
Two different 403s look identical in the browser but have opposite fixes. Open the response body (it is XML) and match the <Code>:
| Error code / message | What it really means | Jump to |
|---|---|---|
AccessDenied + Request has expired | TTL elapsed, or credentials behind the URL expired | Causes 1 & 3 |
SignatureDoesNotMatch | Request headers don’t match what was signed (Content-Type, checksum, host, clock skew) | Causes 4, 5, 6 |
EntityTooLarge | Single PUT over 5 GB | Cause 2 |
EntityTooSmall (multipart) | A non-final part is under 5 MB | See multipart notes |
ExpiredToken | The STS/role credentials used to sign are already revoked | Cause 3 |
If you can’t see the body in the browser, copy the request as cURL from DevTools and run it; S3 returns the XML to the terminal.
Common causes
Ordered by hit rate.
1. Signed URL TTL shorter than upload duration
The signed URL has a 300 s expiry. A user on a 5 Mbps uplink needs roughly 800 s to push 500 MB. S3 checks the deadline at the moment each HTTP request starts, so a single PUT that is still in flight past the deadline is fine — but a connection that drops and retries after expiry, or a multipart upload whose later parts start after the window closes, gets rejected.
How to spot it: Body is <Error><Code>AccessDenied</Code><Message>Request has expired</Message></Error>, and the X-Amz-Expires value in the request URL is small (e.g. 300).
2. Single-part PUT for a file larger than 5 GB
S3 single-part PUT has a hard cap of 5 GB. Even with the TTL fixed, a 6 GB file always fails. (Multipart raises the ceiling to a 5 TB object across up to 10,000 parts.)
How to spot it: Error code EntityTooLarge.
3. The signing credentials expire before the URL does
This is the cause people miss. A presigned URL can never outlive the credentials that signed it. As of June 2026 the AWS docs are explicit: an IAM-user key can sign a URL valid up to 7 days (604800 s), but temporary credentials cap the URL at the credential’s own lifetime, whichever is shorter:
| Signer credential | Effective max URL life |
|---|---|
| IAM user access key (SigV4) | 7 days (604800 s) |
sts:AssumeRole session | role session duration — default 1 hour, max 12 h |
| EC2 instance profile | IMDS rotation, typically ~6 hours |
| ECS/Fargate task role | rotates ~1-6 hours |
| Lambda execution role | the invocation’s STS session, ~minutes to 12 h |
So a Lambda or ECS service that signs with ExpiresIn=86400 (24 h) still hands the browser a URL that dies in an hour (or 12 h at most). After it expires you get 403 Request has expired (the TTL window inherited from the credential) or ExpiredToken (credentials already gone). This is the single most-reported “my URL dies early” case in AWS’s own troubleshooting guide.
How to spot it: URLs work right after deploy but start 403ing an hour or a few hours later, regardless of ExpiresIn; the signer runs on a role, not a long-lived IAM-user key.
Fix: sign with a dedicated IAM-user key that has only s3:PutObject on the upload prefix, or keep TTLs well under the role session length, or re-sign per part for multipart so each URL is fresh.
4. Default SDK checksum header not sent by the browser
AWS shipped default data-integrity protections in late 2024 (announced December 2, 2024), and the updated SDKs that followed turn checksums on by default — CRC64NVME is the default algorithm (CRC32 / CRC32C are also used). When you presign a put_object, the SDK may fold an x-amz-sdk-checksum-algorithm / x-amz-checksum-* header into the signature. If the browser then PUTs the raw file without that header, the signatures don’t match and S3 returns 403 SignatureDoesNotMatch — on the very first byte, not mid-upload. This is why an upload flow that worked in 2023 can start failing after an SDK bump, with no code change of your own.
How to spot it: <Code>SignatureDoesNotMatch</Code>; the error’s <CanonicalRequest> lists an x-amz-checksum-* or x-amz-sdk-checksum-algorithm header your client never sent.
Fix: either disable the default checksum when signing (boto3: Config(request_checksum_calculation='when_required'); JS SDK v3: requestChecksumCalculation: 'WHEN_REQUIRED') so it’s not in the signature, or send the exact same checksum header from the browser.
5. Content-Type or other header signed but not sent (or vice versa)
If you pass ContentType (or any header) into Params when signing, that header is part of the signature. The browser PUT must send the identical value. A common miss: signing with ContentType: 'video/mp4' but letting fetch set its own Content-Type.
How to spot it: 403 SignatureDoesNotMatch; the signed-headers list includes content-type but your request sent a different (or no) value.
6. Clock skew on the signer
If the signer’s clock has drifted backward, S3 sees the signature as already expired the instant it arrives.
How to spot it: 403s on every signed URL, even tiny ones; X-Amz-Date in the URL is already in the past. (Code SignatureDoesNotMatch or Request has expired.)
7. CORS preflight burns the budget / blocks the PUT
The browser sends an OPTIONS preflight before the PUT. If the bucket CORS doesn’t permit PUT, the upload never starts while the signed URL clock keeps ticking.
How to spot it: Network tab shows the preflight 403 (or no PUT request at all); the browser console logs a CORS error.
8. Signature version v2 used with a SigV4-only region
Older code uses signature v2. Regions launched after January 2014 and KMS-encrypted buckets require SigV4.
How to spot it: InvalidRequest: The authorization mechanism you have provided is not supported.
Shortest path to fix
Step 1: Pick the right pattern based on file size
| File size | Pattern |
|---|---|
| Under 100 MB | Single PUT presigned URL, TTL 15 min |
| 100 MB - 5 GB | Multipart with presigned part URLs |
| Over 5 GB | Multipart only (single PUT hard-caps at 5 GB) |
Five-minute TTLs are too tight for anything user-facing. Default to 900 s (15 min) — but never longer than the signer’s credential lifetime (Cause 3).
Step 2: Single-part PUT with a sensible TTL
# Python boto3 — single PUT presigned URL
import boto3
from botocore.client import Config
s3 = boto3.client(
's3', region_name='us-east-1',
config=Config(
signature_version='s3v4',
request_checksum_calculation='when_required', # keep checksum out of the signature (Cause 4)
),
)
url = s3.generate_presigned_url(
'put_object',
Params={'Bucket': 'uploads', 'Key': 'video.mp4', 'ContentType': 'video/mp4'},
ExpiresIn=900, # 15 min
HttpMethod='PUT',
)
Front-end PUTs directly, sending the same Content-Type you signed (Cause 5):
fetch(url, { method: 'PUT', body: file, headers: { 'Content-Type': 'video/mp4' } });
Step 3: Multipart upload with per-part presigned URLs
This is the right answer for big files. Each part gets its own presigned URL; the browser uploads parts in parallel; the backend completes the upload. Part rules: 5 MB minimum per part except the last, 5 GB maximum per part, up to 10,000 parts.
# 1. Initiate
mpu = s3.create_multipart_upload(Bucket='uploads', Key='video.mp4', ContentType='video/mp4')
upload_id = mpu['UploadId']
# 2. Sign part URLs (one per chunk, e.g. 8 MB chunks)
def sign_part(part_number):
return s3.generate_presigned_url(
'upload_part',
Params={
'Bucket': 'uploads',
'Key': 'video.mp4',
'UploadId': upload_id,
'PartNumber': part_number,
},
ExpiresIn=3600,
)
# 3. After the client uploads each part, it returns the ETag from the response header
# 4. Complete
s3.complete_multipart_upload(
Bucket='uploads', Key='video.mp4', UploadId=upload_id,
MultipartUpload={'Parts': [
{'PartNumber': 1, 'ETag': '"abc..."'},
{'PartNumber': 2, 'ETag': '"def..."'},
]},
)
The browser uploads each part with PUT to its URL, reads ETag from the response header, and sends the list back to your backend. If a part 403s with SignatureDoesNotMatch after a retry, re-sign that single part rather than the whole upload.
Step 4: Use the SDK upload manager when you can
For server-side uploads, don’t hand-roll multipart — use the manager. It chunks, parallelizes, retries, and aborts the multipart upload on failure.
// Node AWS SDK v3
import { S3Client } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';
import { createReadStream } from 'node:fs';
const s3 = new S3Client({ region: 'us-east-1' });
await new Upload({
client: s3,
params: {
Bucket: 'uploads',
Key: 'video.mp4',
Body: createReadStream('./video.mp4'),
ContentType: 'video/mp4',
},
queueSize: 4, // parallel parts
partSize: 8 * 1024 * 1024,
}).done();
The Python equivalent is s3.upload_file(...) with a TransferConfig(multipart_threshold=..., max_concurrency=...).
Step 5: Fix clock and CORS
# On the signer host — verify and force-correct the clock (Cause 6)
timedatectl status
sudo chronyc makestep
{
"CORSRules": [
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["GET", "PUT", "POST"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}
]
}
ExposeHeaders: ["ETag"] is required so the browser can read each part’s ETag for the complete call. AllowedHeaders: ["*"] lets the preflight pass any signed header (Content-Type, checksum).
How to confirm it’s fixed
- Upload a file larger than your old failure point (e.g. 600 MB) on a throttled connection — Chrome DevTools Network → throttling → “Slow 4G” — and confirm it completes with
200/204. - Re-run the upload an hour after a fresh deploy if you sign on a role, to rule out Cause 3.
- For multipart, list parts mid-upload and confirm none returned
SignatureDoesNotMatch:aws s3api list-parts --bucket uploads --key video.mp4 --upload-id "$UPLOAD_ID" aws s3api head-object --bucket uploads --key video.mp4should show the fullContentLength.
FAQ
Why does a small file work but a large one 403s mid-upload?
The signed URL’s TTL (and/or the signer’s credential lifetime) is shorter than the time the big file needs to transfer. Small files finish inside the window; large ones run past the deadline and the next request in the sequence is rejected with Request has expired.
What’s the maximum I can set ExpiresIn to?
Via the AWS CLI/SDKs with an IAM-user key, 7 days (604800 s). Via the S3 console, 1 minute to 12 hours. But if you sign with temporary credentials (role / STS / instance profile), the URL still expires when those credentials do — often in 1 hour, and capped at 12 hours for an STS session no matter what you pass.
My URL still 403s after I set ExpiresIn to 24 hours — why?
Almost certainly Cause 3: your backend signs with a role or STS session, so the URL inherits the session’s (shorter) lifetime — commonly 1 hour, at most 12 hours. AWS documents this exact behaviour in its presigned-URL early-expiration guide. Sign with a dedicated IAM-user key, or use per-part re-signing.
I get SignatureDoesNotMatch on the first byte, not Request has expired. Different problem?
Yes. That’s a header mismatch, not expiry. The usual culprits are the SDK’s default checksum header (Cause 4) or a Content-Type you signed but didn’t send (Cause 5). Compare the signed-headers list in the error’s <CanonicalRequest> to what your client actually sent.
Should I just raise the TTL to a week and forget multipart?
No. A long TTL is a security liability (the URL is a bearer token), and a single PUT still caps at 5 GB. Use multipart for big files and keep TTLs short. You can also enforce freshness with an s3:signatureAge condition in the bucket policy.
What part size should I use? 8-16 MB is a good default. Parts must be at least 5 MB (except the final part) and at most 5 GB, with a 10,000-part ceiling — so for very large objects, scale the part size up to stay under 10,000 parts.
Prevention
- Default TTL 15 min for single PUT, 1 hr for multipart parts — and never exceed the signer’s credential lifetime.
- Sign with a dedicated, least-privilege IAM-user key (only
s3:PutObjecton the upload prefix) when you need stable, long-lived URLs. - Anything over 100 MB uses multipart, with parts of 8-16 MB.
- Keep the SDK’s checksum out of the signature (
when_required/WHEN_REQUIRED) unless the client sends the matching header. - Signer host runs NTP; alert if clock skew exceeds 1 s.
- Always set
signature_version='s3v4'and target a SigV4 endpoint. - For server-side uploads, prefer the SDK
Upload/TransferManagerover manual multipart.
Related
- Storage upload denied
- Edge function timeout
- Backend JWT expired clock skew
- Backend cron job skipped silently
- CORS error
- Firebase quota exceeded
External references: AWS S3 presigned URL docs · Troubleshoot SignatureDoesNotMatch · Presigned URL expires before its expiration time
Tags: #Backend #Troubleshooting #s3