I generated an S3 presigned URL for a 24-hour upload window, sent it to a colleague, and 30 minutes later they reported a 403 Forbidden error. The URL should have been valid for hours, but it expired immediately. After debugging, I realized the presigned URL was generated using temporary credentials from an IAM role, which expired far sooner than the URL itself. Understanding the difference between credential expiry and URL expiry saved me from a production disaster. In this post, I’ll walk through exactly what causes this and how to fix it.
The Problem
You generate an S3 presigned URL with a 24-hour expiry, but it fails with 403 Forbidden within minutes or hours. The URL should be valid, but S3 rejects access before the expiry time you specified.
| Error Type | Description |
|---|---|
| 403 Forbidden | Presigned URL is valid but credentials used to sign it have expired. |
| SignatureDoesNotMatch | Clock skew between client and S3 exceeds 15 minutes. |
| AccessDenied | IAM role permissions changed or were revoked after URL generation. |
Why Does This Happen?
- Presigned URL generated with temporary credentials — When you use an IAM role, STS token, or assumed role to generate the URL, the URL expires when the credential session expires, not when you specified. If the session lasts 1 hour, the URL only works for 1 hour, regardless of the
--expires-invalue. - Clock skew between client and S3 — S3 signs requests with a timestamp. If the client’s clock is more than 15 minutes ahead or behind S3’s clock, the signature is rejected. This is often the cause when a URL works for you locally but fails on a user’s machine.
- IAM role permissions were revoked — The presigned URL is signed with the current IAM role’s permissions. If the role’s policy is modified after the URL is generated, the URL may fail even within its expiry window.
--expires-inspecified in minutes instead of seconds — A common mistake: setting--expires-in 60thinking it’s 60 minutes, when it’s actually 60 seconds (1 minute).
The Fix
Step 1: Use Long-Lived Credentials for Presigned URLs
If you need multi-hour or multi-day presigned URLs, use IAM user access keys instead of temporary credentials:
# Generate presigned URL with IAM user credentials (long-lived)
# First, configure the AWS CLI with IAM user credentials
export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
# Generate 24-hour presigned URL
aws s3 presign s3://my-bucket/myfile.txt \
--expires-in 86400 \
--region us-east-1
Note: 86400 seconds = 24 hours. Always use seconds, not minutes.
Step 2: For Temporary Credentials, Use STS GetSessionToken
If you must use temporary credentials, request a session token with a longer duration:
# Get temporary credentials with 12-hour duration
aws sts get-session-token \
--duration-seconds 43200 \
--output json > credentials.json
# Extract and export credentials
export AWS_ACCESS_KEY_ID=$(jq -r '.Credentials.AccessKeyId' credentials.json)
export AWS_SECRET_ACCESS_KEY=$(jq -r '.Credentials.SecretAccessKey' credentials.json)
export AWS_SESSION_TOKEN=$(jq -r '.Credentials.SessionToken' credentials.json)
# Generate presigned URL (expires when session expires, up to 12 hours)
aws s3 presign s3://my-bucket/myfile.txt --expires-in 43200
Step 3: Fix Clock Skew Issues
If a URL works for you but fails for users in other locations:
# On the client machine, synchronize the system clock
# macOS/Linux:
sudo ntpdate -s time.nist.gov
# Windows:
w32tm /resync
# Verify clock is correct
date
For applications, use a time synchronization library before generating presigned URLs.
Step 4: Verify IAM Role Permissions
Ensure the IAM role generating the presigned URL has s3:GetObject or s3:PutObject permissions:
# Check the role's policy
aws iam get-role-policy \
--role-name MyRole \
--policy-name MyPolicy
# If permissions are missing, add them
cat > policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject"
],
"Resource": "arn:aws:s3:::my-bucket/*"
}
]
}
EOF
aws iam put-role-policy \
--role-name MyRole \
--policy-name s3-presigned-access \
--policy-document file://policy.json
Step 5: Testing Presigned URLs
Test a presigned URL with curl to check expiry and access:
# Generate a presigned URL
PRESIGNED_URL=$(aws s3 presign s3://my-bucket/myfile.txt --expires-in 3600)
# Test immediate access
curl -I "$PRESIGNED_URL"
# Test after waiting (within expiry window)
sleep 300
curl -I "$PRESIGNED_URL"
If you see 403 before the expiry time, the credentials have expired.
How to Run This
- For long-lived URLs, use IAM user access keys (not role credentials)
- For short-term URLs, ensure temporary credentials have sufficient duration
- Always specify
--expires-inin seconds (not minutes) - Test the URL immediately after generating it
- For multi-user scenarios, ensure all machines have synchronized clocks via NTP
Is This Safe?
Presigned URLs are safe when scoped to specific objects and short expiry times. For user uploads, set expiry to 1 hour or less. For downloads, use 24-hour expiry only for trusted users. Always combine presigned URLs with IAM policies that limit what actions are allowed.
Key Takeaway
Presigned URL expiry is determined by the credential lifetime, not just the --expires-in parameter. Use long-lived IAM user credentials for multi-hour URLs, ensure synchronized clocks, and test URLs immediately after generation to catch issues early.
Have questions or ran into a different S3 issue? Connect with me on LinkedIn or X.