Presigned URLs and secure file access
S3 buckets are private by default. To let users download (or upload) files, you have two bad options: open the bucket (everyone can read everything) or proxy files through your backend (slow, expensive). Presigned URLs: a cryptographically-signed, temporary link that grants access without opening the bucket.
4 min read
The problem: how do you share S3 files?
You've stored a user's resume in a private S3 bucket. Now they want to download it. You can't give them the S3 URL directly — the bucket is private, they'd get a 403 Forbidden. You have bad options:
- Open the bucket publicly: anyone who guesses the URL can read everyone's resumes — terrible security.
- Proxy through your backend:
GET /files/:id→ backend reads from S3 → returns to user. This works but is slow (every file read goes through your server), expensive (you pay for all the bandwidth), and doesn't scale well.
Presigned URLs solve this: your backend generates a temporary, cryptographically-signed URL that grants access to one specific file for a limited time without opening the bucket.
How presigned URLs work
Your backend generates a URL that includes:
- The file path in S3
- An expiration time (e.g., "valid for 1 hour")
- An HMAC signature proving the backend created this URL (so users can't forge their own)
from botocore.client import Config
s3 = boto3.client('s3')
# Generate a URL valid for 1 hour
url = s3.generate_presigned_url(
'get_object',
Params={'Bucket': 'my-files', 'Key': 'resumes/alice.pdf'},
ExpiresIn=3600
)
# Returns something like:
# https://my-files.s3.amazonaws.com/resumes/alice.pdf?
# X-Amz-Algorithm=AWS4-HMAC-SHA256&
# X-Amz-Credential=...&
# X-Amz-Date=...&
# X-Amz-Expires=3600&
# X-Amz-SignedHeaders=...&
# X-Amz-Signature=...The URL looks ugly, but it works. The user (or anyone with the URL) can download the file until the expiration time passes. After that, the signature becomes invalid and S3 returns a 403.
Upload with presigned URLs: direct-to-S3 uploads
For downloads, presigned URLs are nice-to-have. For uploads, they're essential to scale.
Traditional flow:
- User picks a file
- Uploads to your backend (large file, takes time)
- Backend stores in S3
- Response sent to user
Problems: your server becomes a bottleneck; you pay bandwidth costs for the same data traveling through your infrastructure twice (user → you → S3).
Presigned upload flow:
- User picks a file
- Your backend generates a presigned PUT URL and sends it to the user
- User uploads directly to S3 (your backend never touches the file bytes)
- User's browser/app sends a POST or PUT to the presigned URL with the file
- S3 validates the signature and stores the file
// Backend generates presigned URL for upload
const url = await s3.getSignedUrlPromise('putObject', {
Bucket: 'my-files',
Key: `uploads/${userId}/document.pdf`,
Expires: 300 // valid for 5 minutes
});
// Return to frontend
return { uploadUrl: url };// Frontend (React, Vue, etc.)
const formData = new FormData();
formData.append('file', fileInput.files[0]);
const response = await fetch(uploadUrl, {
method: 'PUT',
body: fileInput.files[0]
});
if (response.ok) {
console.log('Uploaded directly to S3');
}Security: three checks before signing anything
Presigned URLs are powerful — they grant access without checking your backend at request time. You must validate before generating the URL:
1. Content type: only allow specific MIME types (no executables, only PDFs and images).
ALLOWED_TYPES = {'application/pdf', 'image/jpeg', 'image/png', 'image/webp'}
if request.content_type not in ALLOWED_TYPES:
return 403 # Forbidden2. File size: cap uploads per type (images at 10 MB, PDFs at 50 MB).
MAX_SIZES = {
'image/jpeg': 10 * 1024 * 1024, # 10 MB
'application/pdf': 50 * 1024 * 1024 # 50 MB
}
if file_size > MAX_SIZES.get(content_type, 0):
return 413 # Payload Too Large3. Ownership: the file must belong to the requesting user/organization.
# Check that the target folder/entity is owned by the user
if not user_owns_folder(request.user_id, folder_id):
return 403 # Forbidden
# Only then generate the presigned URL
url = s3.generate_presigned_url(...)Skipping the third check is a classic multi-tenant security leak — any authenticated user can generate a presigned upload URL for someone else's folder by enumerating folder IDs.
Expiration: balancing usability and security
Short expiration times are safer but less usable. The right balance depends on context:
- Upload URLs: 5 minutes (user picks file immediately after requesting URL)
- Download URLs: 1 hour (user might click the link later; sharing within a team)
- Public, long-lived sharing: days (if someone intentionally shares a link; still prefer a password/permission layer)
# Dynamic expiration based on type
if upload:
expires = 300 # 5 minutes
elif shared_link:
expires = 86400 * 7 # 7 days
else:
expires = 3600 # 1 hourCommon gotchas
CORS headers: if the frontend makes a direct PUT to S3, the browser enforces CORS. S3 must be configured to allow the request origin:
resource "aws_s3_bucket_cors_configuration" "uploads" {
bucket = aws_s3_bucket.my_files.id
cors_rule {
allowed_methods = ["GET", "PUT"]
allowed_origins = ["https://myapp.example.com"]
expose_headers = ["ETag"]
max_age_seconds = 3000
}
}Checksum validation (AWS SDK v3): newer SDK versions attach checksum headers to presigned requests. Browsers can't compute these, so uploads fail with a signature mismatch. Disable automatic checksums:
s3 = boto3.client(
's3',
config=Config(
s3={'payload_signing_enabled': False}
)
)Revalidation on every use: never cache presigned URLs. Generate fresh URLs per request so ownership/permissions are rechecked every time.