AWS S3 Presigned URLs and Access Points: Secure File Sharing
S3 presigned URLs are one of the most widely used patterns in cloud architecture — and one of the most frequently misconfigured. They let you grant time-limited, cryptographically signed access to private S3 objects without exposing your IAM credentials or opening your bucket to the public. Combined with S3 Access Points, Multi-Region Access Points, and Object Lambda, they form the backbone of secure, scalable file sharing in modern cloud applications. This guide covers everything: how SigV4 signing works under the hood, generating presigned URLs with Python boto3, Java SDK v2, and CLI, implementing direct browser uploads, securing URLs against abuse, and wiring up the full Access Points ecosystem — with production-ready code throughout.
Table of Contents
- 1. How Presigned URLs Work — SigV4 Signing, GET vs PUT vs DELETE, Expiry Limits
- 2. Generating Presigned URLs — Python boto3, Java SDK v2, AWS CLI
- 3. Direct Browser Upload — Presigned PUT, React Drag-and-Drop, Multipart
- 4. Presigned URL Security — VPC Conditions, IP Restrictions, CloudFront Comparison
- 5. S3 Access Points — Per-Team Delegation, VPC Restriction, Alias URLs
- 6. Multi-Region Access Points — Global Routing, Failover, Terraform
- 7. Object Lambda Access Points — On-the-Fly Transforms, PII Redaction
- 8. Large File Uploads — Multipart Upload with Per-Part Presigned URLs
- 9. Presigned URL Patterns — Download Links, User Upload Slots, CDN Signed Cookies
- 10. Common Pitfalls — Clock Skew, CORS, Bucket Policy Conflicts, STS Expiry
1. How Presigned URLs Work — SigV4 Signing, GET vs PUT vs DELETE, Expiry Limits
A presigned URL encodes everything needed to perform a single S3 operation — the bucket, key, HTTP method, expiry time, and a cryptographic signature — directly in the URL itself. When the URL is presented to S3, S3 recomputes the expected signature using the same IAM credentials and verifies it matches the query parameter. No session, no token exchange, no Lambda in the middle. The signer's permissions are evaluated at request time, not at signing time, which is a subtle but critical distinction: if the IAM role that signed the URL loses the necessary S3 permission before the URL is used, S3 will reject the request even if the URL has not expired.
The signing algorithm is AWS Signature Version 4 (SigV4). It creates a canonical request string (HTTP method + URI + query params + headers + hashed payload), hashes it, signs the hash with the signer's secret key via HMAC-SHA256, and appends the result as X-Amz-Signature in the query string. The URL also carries X-Amz-Algorithm, X-Amz-Credential, X-Amz-Date, X-Amz-Expires, and X-Amz-SignedHeaders. S3 uses these to reconstruct the canonical form and verify the signature independently.
Supported Operations
- GET — Download an object. The most common use case: time-limited download links for private files.
- PUT — Upload an object directly to S3. Used for client-side uploads that bypass your application server.
- DELETE — Delete an object. Use rarely — generating delete presigned URLs is a high-risk operation.
- HEAD — Retrieve object metadata (size, content-type, ETag) without downloading the body. Useful for file existence checks from the browser.
- POST (S3 Browser Uploads) — A related mechanism using form fields and a policy document rather than query string signing. More flexible for HTML form-based uploads but harder to use with modern JS fetch.
Expiry Limits
Expiry limits depend on the type of credentials used to sign:
- IAM user (long-term credentials): maximum 7 days (604800 seconds)
- IAM role via STS AssumeRole (temporary credentials): maximum is the shorter of the
X-Amz-Expiresvalue or the STS session duration (default 1 hour, max 12 hours for roles without extended session support) - EC2 instance profile / ECS task role / Lambda execution role: signed with STS temporary credentials, so the URL expires when the underlying session expires — potentially as short as 15 minutes for short-lived role sessions
boto3.Session with IAM user credentials stored in Secrets Manager, separate from your instance role.
2. Generating Presigned URLs — Python boto3, Java SDK v2, AWS CLI
The mechanics of generating a presigned URL are straightforward in every AWS SDK. The SDK uses whatever credentials are configured — environment variables, instance profile, or an explicit session — and produces a fully signed URL string. You can then hand that string to any HTTP client, embed it in an email, or return it from an API.
Python boto3 — GET and PUT
import boto3
from botocore.exceptions import ClientError
s3_client = boto3.client('s3', region_name='us-east-1')
# Generate a GET presigned URL (download) — valid for 1 hour
def generate_download_url(bucket: str, key: str, expiry_seconds: int = 3600) -> str:
try:
url = s3_client.generate_presigned_url(
ClientMethod='get_object',
Params={
'Bucket': bucket,
'Key': key,
# Optional: force browser to download rather than preview
'ResponseContentDisposition': f'attachment; filename="{key.split("/")[-1]}"',
# Optional: override Content-Type in the response
'ResponseContentType': 'application/octet-stream',
},
ExpiresIn=expiry_seconds,
HttpMethod='GET',
)
return url
except ClientError as e:
raise RuntimeError(f"Could not generate presigned URL: {e}") from e
# Generate a PUT presigned URL (upload) — valid for 15 minutes
def generate_upload_url(bucket: str, key: str, content_type: str, expiry_seconds: int = 900) -> str:
url = s3_client.generate_presigned_url(
ClientMethod='put_object',
Params={
'Bucket': bucket,
'Key': key,
'ContentType': content_type,
# Optional: enforce server-side encryption
'ServerSideEncryption': 'AES256',
# Optional: tag the object on upload
'Tagging': 'source=browser-upload&env=prod',
},
ExpiresIn=expiry_seconds,
HttpMethod='PUT',
)
return url
# Usage
download_link = generate_download_url('my-private-bucket', 'reports/q1-2026.pdf', expiry_seconds=86400)
upload_link = generate_upload_url('my-private-bucket', 'uploads/user-42/avatar.png', 'image/png')
print("Download:", download_link)
print("Upload: ", upload_link)
ContentType parameter, the browser or HTTP client MUST send the same Content-Type header in its PUT request. A mismatch causes a 403 SignatureDoesNotMatch error. Always pass the content type to the frontend and include it in the fetch request headers.
Java AWS SDK v2
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import java.time.Duration;
import java.net.URL;
public class S3PresignedUrlService {
private final S3Presigner presigner;
public S3PresignedUrlService() {
this.presigner = S3Presigner.builder()
.region(Region.US_EAST_1)
.credentialsProvider(DefaultCredentialsProvider.create())
.build();
}
// Generate a GET presigned URL — 1 hour
public URL generateGetUrl(String bucket, String key, Duration expiry) {
GetObjectRequest getRequest = GetObjectRequest.builder()
.bucket(bucket)
.key(key)
.build();
GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
.signatureDuration(expiry)
.getObjectRequest(getRequest)
.build();
return presigner.presignGetObject(presignRequest).url();
}
// Generate a PUT presigned URL — 15 minutes
public URL generatePutUrl(String bucket, String key, String contentType, Duration expiry) {
PutObjectRequest putRequest = PutObjectRequest.builder()
.bucket(bucket)
.key(key)
.contentType(contentType)
.serverSideEncryption("AES256")
.build();
PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
.signatureDuration(expiry)
.putObjectRequest(putRequest)
.build();
return presigner.presignPutObject(presignRequest).url();
}
public static void main(String[] args) {
S3PresignedUrlService service = new S3PresignedUrlService();
URL downloadUrl = service.generateGetUrl("my-bucket", "docs/manual.pdf", Duration.ofHours(1));
URL uploadUrl = service.generatePutUrl("my-bucket", "uploads/report.csv", "text/csv", Duration.ofMinutes(15));
System.out.println("Download: " + downloadUrl);
System.out.println("Upload: " + uploadUrl);
}
}
AWS CLI
# GET presigned URL — expires in 3600 seconds (1 hour)
aws s3 presign s3://my-private-bucket/reports/q1-2026.pdf \
--expires-in 3600 \
--region us-east-1
# The CLI does not support PUT presigned URLs directly.
# Use the Python one-liner instead:
python3 -c "
import boto3, sys
s3 = boto3.client('s3')
url = s3.generate_presigned_url(
'put_object',
Params={'Bucket': sys.argv[1], 'Key': sys.argv[2], 'ContentType': 'application/octet-stream'},
ExpiresIn=900
)
print(url)
" my-private-bucket uploads/datafile.csv
3. Direct Browser Upload — Presigned PUT, React Drag-and-Drop, Multipart
The presigned PUT URL pattern offloads file uploads from your application server directly to S3. Your backend generates a short-lived URL and returns it to the frontend; the browser then PUTs the file bytes directly to S3 using that URL. Your server never touches the file bytes — it only orchestrates the URL generation. This dramatically reduces bandwidth costs and removes your server as a bottleneck for large file uploads.
The flow is: (1) frontend asks your API for an upload URL, (2) backend generates a presigned PUT URL and returns it along with the final object key, (3) frontend PUTs the file directly to S3 using that URL, (4) frontend notifies your backend that the upload is complete so it can trigger any post-processing (virus scan, metadata extraction, Lambda trigger, etc.).
React Drag-and-Drop Upload Component
// FileUploader.jsx — React component for drag-and-drop S3 upload
import { useState, useCallback } from 'react';
async function requestUploadUrl(fileName, fileType) {
const response = await fetch('/api/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName, fileType }),
});
if (!response.ok) throw new Error('Failed to get upload URL');
return response.json(); // { uploadUrl, objectKey }
}
async function uploadToS3(presignedUrl, file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', presignedUrl);
// CRITICAL: Content-Type must match what the server used when generating the URL
xhr.setRequestHeader('Content-Type', file.type);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
onProgress(Math.round((event.loaded / event.total) * 100));
}
};
xhr.onload = () => xhr.status === 200 ? resolve() : reject(new Error(`Upload failed: ${xhr.status}`));
xhr.onerror = () => reject(new Error('Network error during upload'));
xhr.send(file);
});
}
export default function FileUploader({ onUploadComplete }) {
const [isDragging, setIsDragging] = useState(false);
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState('idle'); // idle | uploading | done | error
const handleFile = useCallback(async (file) => {
try {
setStatus('uploading');
setProgress(0);
const { uploadUrl, objectKey } = await requestUploadUrl(file.name, file.type);
await uploadToS3(uploadUrl, file, setProgress);
setStatus('done');
onUploadComplete?.({ objectKey, fileName: file.name, size: file.size });
} catch (err) {
console.error(err);
setStatus('error');
}
}, [onUploadComplete]);
const onDrop = useCallback((e) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
}, [handleFile]);
return (
<div
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={onDrop}
style={{
border: `2px dashed ${isDragging ? '#6366f1' : '#64748b'}`,
borderRadius: '12px',
padding: '2rem',
textAlign: 'center',
transition: 'border-color .2s',
}}
>
{status === 'idle' && <p>Drag & drop a file, or <label>browse<input type="file" style={{display:'none'}} onChange={e => handleFile(e.target.files[0])} /></label></p>}
{status === 'uploading' && <p>Uploading... {progress}%</p>}
{status === 'done' && <p style={{color:'#22d3ee'}}>Upload complete!</p>}
{status === 'error' && <p style={{color:'#ef4444'}}>Upload failed. Please retry.</p>}
</div>
);
}
Backend API Endpoint (Node.js / Express)
// server.js — Node.js Express endpoint that generates the presigned URL
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const { v4: uuidv4 } = require('uuid');
const s3 = new S3Client({ region: 'us-east-1' });
app.post('/api/upload-url', async (req, res) => {
const { fileName, fileType } = req.body;
const userId = req.user.id; // from your auth middleware
// Namespace uploads by user to prevent key collisions and enable per-user policies
const objectKey = `uploads/${userId}/${uuidv4()}-${fileName}`;
const command = new PutObjectCommand({
Bucket: process.env.UPLOAD_BUCKET,
Key: objectKey,
ContentType: fileType,
ServerSideEncryption: 'AES256',
Metadata: {
'uploaded-by': userId,
'original-name': fileName,
},
});
const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 300 }); // 5 minutes
res.json({ uploadUrl, objectKey });
});
4. Presigned URL Security — VPC Conditions, IP Restrictions, CloudFront Comparison
Presigned URLs are powerful but carry real security risks if misused. Because the signature is embedded in the URL, anyone who obtains the URL can use it until it expires. They can forward it, post it publicly, or embed it in their own pages. Defending against URL sharing requires a combination of short expiry times, bucket policy conditions, and architectural controls.
VPC Endpoint Condition — Restrict Presigned URLs to VPC Traffic Only
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyAccessOutsideVPCEndpoint",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-internal-bucket/*",
"Condition": {
"StringNotEquals": {
"aws:SourceVpce": "vpce-0a1b2c3d4e5f6789a"
}
}
}
]
}
This policy denies all GetObject requests that do not originate from your specific VPC endpoint, even if a valid presigned URL is presented. This is the strongest control available — presigned URLs generated for internal microservices are simply unusable outside your VPC, even if leaked.
IP Address Restriction
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowFromCorporateIP",
"Effect": "Deny",
"Principal": "*",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::my-sensitive-bucket/*",
"Condition": {
"NotIpAddress": {
"aws:SourceIp": [
"203.0.113.0/24",
"198.51.100.0/24"
]
}
}
}
]
}
Preventing URL Sharing with Short Expiry + Audit Trail
For user-facing download links, the most practical defense is short expiry (60–300 seconds) combined with generating a new URL on each page load through your API. Log every presigned URL generation event to CloudTrail and correlate with the authenticated user ID. If your use case allows, use CloudFront signed URLs with key pairs instead — they support more granular conditions including referer, IP, and custom policy duration without the presigned URL's per-object signing overhead.
CloudFront Signed URLs vs S3 Presigned URLs
| Feature | S3 Presigned URL | CloudFront Signed URL |
|---|---|---|
| Signing entity | IAM credential | CloudFront key pair |
| CDN caching | No (goes direct to S3) | Yes (edge cache) |
| IP restriction | Via bucket policy | Built into signed URL |
| Start time condition | No | Yes |
| Wildcard paths | No | Yes (signed cookies) |
| Max expiry | 7 days (IAM user) | Unlimited |
| Direct S3 bucket exposure | Yes | No (OAC) |
5. S3 Access Points — Per-Team Delegation, VPC Restriction, Alias URLs
S3 Access Points solve a fundamental governance problem: a single S3 bucket shared by multiple teams or applications requires a complex, ever-growing bucket policy that becomes unmaintainable at scale. An Access Point is a named network endpoint that has its own policy — separate from the bucket policy — scoped to a specific prefix, set of principals, or network origin. Each team gets their own Access Point with their own policy, and the bucket policy simply delegates to the Access Points.
Each bucket can have up to 10,000 Access Points. Access Points have their own ARN and DNS hostname (in the format ACCOUNT_ID.s3-accesspoint.REGION.amazonaws.com). You reference them in IAM policies and SDK calls exactly where you would reference a bucket.
Creating Access Points via CLI
# Create an access point for the data-engineering team, scoped to their prefix
aws s3control create-access-point \
--account-id 123456789012 \
--name data-eng-access-point \
--bucket my-shared-data-lake \
--region us-east-1
# Create a VPC-restricted access point (no internet access)
aws s3control create-access-point \
--account-id 123456789012 \
--name analytics-vpc-only \
--bucket my-shared-data-lake \
--vpc-configuration VpcId=vpc-0a1b2c3d4e5f \
--region us-east-1
# List all access points for a bucket
aws s3control list-access-points \
--account-id 123456789012 \
--bucket my-shared-data-lake
Access Point Policy — Scope Team Access to a Prefix
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowDataEngTeam",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/DataEngTeamRole"
},
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:us-east-1:123456789012:accesspoint/data-eng-access-point/object/data-eng/*",
"arn:aws:s3:us-east-1:123456789012:accesspoint/data-eng-access-point"
],
"Condition": {
"StringEquals": {
"s3:prefix": "data-eng/"
}
}
}
]
}
Bucket Delegation Policy — Required on the Bucket Itself
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DelegateToAccessPoints",
"Effect": "Allow",
"Principal": { "AWS": "*" },
"Action": "*",
"Resource": [
"arn:aws:s3:::my-shared-data-lake",
"arn:aws:s3:::my-shared-data-lake/*"
],
"Condition": {
"StringEquals": {
"s3:DataAccessPointAccount": "123456789012"
}
}
}
]
}
Using Access Point Alias URLs with boto3
import boto3
s3 = boto3.client('s3', region_name='us-east-1')
# Access Point ARN format
access_point_arn = 'arn:aws:s3:us-east-1:123456789012:accesspoint/data-eng-access-point'
# Use the ARN exactly where you would use a bucket name
response = s3.list_objects_v2(Bucket=access_point_arn, Prefix='data-eng/')
for obj in response.get('Contents', []):
print(obj['Key'])
# Presigned URL via Access Point ARN — works identically to bucket presigned URLs
url = s3.generate_presigned_url(
'get_object',
Params={'Bucket': access_point_arn, 'Key': 'data-eng/report.csv'},
ExpiresIn=3600,
)
print(url)
6. Multi-Region Access Points — Global Routing, Failover, Terraform
S3 Multi-Region Access Points (MRAP) provide a single global endpoint that routes requests to the lowest-latency S3 bucket across multiple AWS regions. Underneath, MRAP uses AWS Global Accelerator's anycast network. Your application calls one endpoint and AWS automatically routes the request to the closest bucket. MRAP is especially powerful for globally distributed applications, disaster recovery setups with active-active replication, and latency-sensitive content delivery.
MRAP is not the same as S3 Cross-Region Replication — you still need CRR to keep the buckets in sync. MRAP handles the routing; CRR handles the data consistency. Together they form an active-active global S3 architecture.
Terraform Setup — MRAP with Two Buckets
resource "aws_s3_bucket" "primary" {
bucket = "my-app-data-us-east-1"
provider = aws.us_east_1
}
resource "aws_s3_bucket" "replica" {
bucket = "my-app-data-eu-west-1"
provider = aws.eu_west_1
}
resource "aws_s3_multi_region_access_point" "global" {
details {
name = "my-global-access-point"
region {
bucket = aws_s3_bucket.primary.id
}
region {
bucket = aws_s3_bucket.replica.id
}
}
}
# Failover routing policy — prefer us-east-1, failover to eu-west-1
resource "aws_s3control_multi_region_access_point_policy" "failover" {
details {
account_id = data.aws_caller_identity.current.account_id
name = aws_s3_multi_region_access_point.global.details[0].name
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { AWS = aws_iam_role.app_role.arn }
Action = ["s3:GetObject", "s3:PutObject", "s3:ListBucket"]
Resource = [
"arn:aws:s3::${data.aws_caller_identity.current.account_id}:accesspoint/my-global-access-point",
"arn:aws:s3::${data.aws_caller_identity.current.account_id}:accesspoint/my-global-access-point/object/*",
]
}]
})
}
}
AWS CLI — Routing Control for Active/Passive Failover
# Get the MRAP ARN
MRAP_ARN=$(aws s3control get-multi-region-access-point \
--account-id 123456789012 \
--name my-global-access-point \
--query 'AccessPoint.Alias' \
--output text)
# The MRAP global endpoint has this format:
# ALIAS.mrap.accesspoint.s3-global.amazonaws.com
# Submit a routing control update — send traffic to eu-west-1 only (failover)
aws s3control submit-multi-region-access-point-routes \
--account-id 123456789012 \
--mrap arn:aws:s3::123456789012:accesspoint/my-global-access-point \
--route-updates '[
{"Bucket":"my-app-data-us-east-1","TrafficDialPercentage":0},
{"Bucket":"my-app-data-eu-west-1","TrafficDialPercentage":100}
]'
7. Object Lambda Access Points — On-the-Fly Transforms, PII Redaction
S3 Object Lambda Access Points let you intercept S3 GetObject calls and transform the data on the fly using a Lambda function before it reaches the caller. Instead of storing multiple versions of a file (original, watermarked, redacted, resized), you store one canonical copy and let Lambda transform it at read time per caller identity or request context. Common use cases include watermarking downloaded images with the user's email, redacting PII fields from CSV/JSON exports, resizing images for different device viewports, and decrypting custom-encrypted content.
Lambda Function — CSV PII Redaction
import boto3
import json
import csv
import io
import urllib.request
s3_client = boto3.client('s3')
# Columns to redact based on the caller's IAM role
PII_COLUMNS = {'email', 'phone', 'ssn', 'credit_card', 'date_of_birth'}
def lambda_handler(event, context):
# Object Lambda passes the presigned URL to fetch the original object
object_get_context = event['getObjectContext']
request_route = object_get_context['outputRoute']
request_token = object_get_context['outputToken']
s3_url = object_get_context['inputS3Url']
# Check caller identity to determine redaction level
caller_arn = event.get('userRequest', {}).get('headers', {}).get('x-amz-security-token', '')
should_redact = 'AnalyticsRole' not in caller_arn # Only full access for analytics role
# Fetch the original CSV from S3 via the presigned URL
response = urllib.request.urlopen(s3_url)
original_csv = response.read().decode('utf-8')
if should_redact:
# Parse and redact PII columns
reader = csv.DictReader(io.StringIO(original_csv))
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=reader.fieldnames)
writer.writeheader()
for row in reader:
redacted_row = {
col: ('***REDACTED***' if col.lower() in PII_COLUMNS else val)
for col, val in row.items()
}
writer.writerow(redacted_row)
transformed_csv = output.getvalue()
else:
transformed_csv = original_csv
# Write the transformed object back to the caller
s3_client.write_get_object_response(
Body=transformed_csv.encode('utf-8'),
RequestRoute=request_route,
RequestToken=request_token,
ContentType='text/csv',
StatusCode=200,
)
return {'statusCode': 200}
Creating the Object Lambda Access Point
# Step 1: Create a supporting standard Access Point
aws s3control create-access-point \
--account-id 123456789012 \
--name supporting-ap-for-object-lambda \
--bucket my-data-bucket
# Step 2: Create the Object Lambda Access Point
aws s3control create-access-point-for-object-lambda \
--account-id 123456789012 \
--name pii-redaction-olap \
--configuration '{
"SupportingAccessPoint": "arn:aws:s3:us-east-1:123456789012:accesspoint/supporting-ap-for-object-lambda",
"TransformationConfigurations": [
{
"Actions": ["GetObject"],
"ContentTransformation": {
"AwsLambda": {
"FunctionArn": "arn:aws:lambda:us-east-1:123456789012:function:s3-pii-redaction"
}
}
}
]
}'
# Clients use the OLAP ARN as the bucket — transparent to existing code
OLAP_ARN="arn:aws:s3-object-lambda:us-east-1:123456789012:accesspoint/pii-redaction-olap"
aws s3api get-object --bucket "$OLAP_ARN" --key "customer-data/2026-q1.csv" output.csv
8. Large File Uploads — Multipart Upload with Per-Part Presigned URLs
The standard presigned PUT URL has a 5 GB limit per request. For files larger than 5 GB (or for better reliability and resume support on any large file), you need S3 multipart upload combined with per-part presigned URLs. The pattern works in three phases: (1) initiate a multipart upload to get an upload ID, (2) generate one presigned URL per part (each URL is scoped to a specific part number), (3) after all parts are uploaded, complete the multipart upload by providing the ETag of each part.
Part sizes must be at least 5 MB (except the last part), so a 100 MB file should use parts of 5–10 MB each, giving 10–20 parts. AWS supports up to 10,000 parts per multipart upload, making the theoretical max 5 TB (part limit × max part size). Incomplete multipart uploads accumulate storage charges, so always configure a lifecycle rule to abort them after 7 days.
Python — Full Multipart Presigned Upload Flow
import boto3
import math
import os
import requests # pip install requests
BUCKET = 'my-upload-bucket'
PART_SIZE = 10 * 1024 * 1024 # 10 MB per part
s3_client = boto3.client('s3', region_name='us-east-1')
def multipart_presigned_upload(file_path: str, object_key: str) -> dict:
file_size = os.path.getsize(file_path)
num_parts = math.ceil(file_size / PART_SIZE)
print(f"Uploading {file_size / (1024**2):.1f} MB in {num_parts} parts")
# Phase 1: Initiate multipart upload
mpu = s3_client.create_multipart_upload(
Bucket=BUCKET,
Key=object_key,
ServerSideEncryption='AES256',
)
upload_id = mpu['UploadId']
print(f"Upload ID: {upload_id}")
# Phase 2: Generate presigned URL per part and upload
parts = []
try:
with open(file_path, 'rb') as f:
for part_number in range(1, num_parts + 1):
chunk = f.read(PART_SIZE)
if not chunk:
break
# Generate presigned URL for this specific part
presigned_url = s3_client.generate_presigned_url(
ClientMethod='upload_part',
Params={
'Bucket': BUCKET,
'Key': object_key,
'UploadId': upload_id,
'PartNumber': part_number,
},
ExpiresIn=3600,
)
# Upload the chunk directly to S3
response = requests.put(presigned_url, data=chunk)
response.raise_for_status()
etag = response.headers['ETag']
parts.append({'PartNumber': part_number, 'ETag': etag})
print(f" Part {part_number}/{num_parts} uploaded — ETag: {etag}")
except Exception as e:
# Phase 2 failed — abort the multipart upload to avoid storage charges
print(f"Upload failed, aborting: {e}")
s3_client.abort_multipart_upload(
Bucket=BUCKET, Key=object_key, UploadId=upload_id
)
raise
# Phase 3: Complete the multipart upload
result = s3_client.complete_multipart_upload(
Bucket=BUCKET,
Key=object_key,
UploadId=upload_id,
MultipartUpload={'Parts': parts},
)
print(f"Upload complete: {result['Location']}")
return result
# Lifecycle rule to clean up abandoned multipart uploads
def configure_abort_lifecycle(bucket: str):
s3_client.put_bucket_lifecycle_configuration(
Bucket=bucket,
LifecycleConfiguration={
'Rules': [{
'ID': 'AbortIncompleteMultipartUploads',
'Status': 'Enabled',
'AbortIncompleteMultipartUpload': {'DaysAfterInitiation': 7},
'Filter': {'Prefix': ''},
}]
}
)
if __name__ == '__main__':
configure_abort_lifecycle(BUCKET)
multipart_presigned_upload('/tmp/large-dataset.zip', 'datasets/large-dataset.zip')
9. Presigned URL Patterns — Download Links, User Upload Slots, CDN Signed Cookies
Presigned URLs are not just for simple file downloads. Several recurring architectural patterns exploit their properties to solve common application problems cleanly and without complex infrastructure.
Pattern 1: Time-Limited User Download Links (SaaS Reports)
SaaS applications commonly generate per-user reports (PDFs, CSVs, exports) and store them in S3 with an uploads/{userId}/ prefix. Each time the user accesses their dashboard, the backend generates a fresh 5-minute presigned GET URL. The URL is short-lived to prevent sharing, and the IAM policy restricts report generation to the user's own prefix.
def get_user_report_url(user_id: str, report_name: str) -> str:
key = f"reports/{user_id}/{report_name}"
return s3_client.generate_presigned_url(
'get_object',
Params={
'Bucket': BUCKET,
'Key': key,
# Force Content-Disposition to trigger download
'ResponseContentDisposition': f'attachment; filename="{report_name}"',
},
ExpiresIn=300, # 5 minutes
)
Pattern 2: User Upload Slots with Key Namespacing
To prevent users from overwriting each other's files or guessing object keys, generate upload URLs with UUID-namespaced keys server-side. The user never chooses their S3 key — only your backend decides where the file lands.
import uuid
def allocate_upload_slot(user_id: str, file_name: str, content_type: str) -> dict:
slot_id = str(uuid.uuid4())
key = f"uploads/{user_id}/{slot_id}/{file_name}"
upload_url = s3_client.generate_presigned_url(
'put_object',
Params={'Bucket': UPLOAD_BUCKET, 'Key': key, 'ContentType': content_type},
ExpiresIn=300,
)
# Store the slot in your DB so you can verify the upload later
save_upload_slot(slot_id=slot_id, user_id=user_id, s3_key=key, status='pending')
return {'uploadUrl': upload_url, 'slotId': slot_id, 'key': key}
Pattern 3: CloudFront Signed Cookies for Multi-File Protected Areas
If a user needs access to many files simultaneously (e.g., a course with 50 video lessons), CloudFront signed cookies are more efficient than generating 50 individual signed URLs. A single cookie grants access to all files under a path prefix for the cookie's validity period.
from botocore.signers import CloudFrontSigner
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
import datetime, base64
def rsa_signer(message):
"""Sign message with CloudFront private key"""
with open('/secrets/cloudfront-private-key.pem', 'rb') as f:
private_key = load_pem_private_key(f.read(), password=None)
return private_key.sign(message, padding.PKCS1v15(), hashes.SHA1())
cf_signer = CloudFrontSigner(CLOUDFRONT_KEY_ID, rsa_signer)
def generate_signed_cookie(user_id: str, course_id: str) -> dict:
policy = {
"Statement": [{
"Resource": f"https://cdn.example.com/courses/{course_id}/*",
"Condition": {
"DateLessThan": {"AWS:EpochTime": int((datetime.datetime.utcnow() + datetime.timedelta(days=1)).timestamp())},
"IpAddress": {"AWS:SourceIp": "0.0.0.0/0"},
}
}]
}
return cf_signer.generate_presigned_cookies(policy=json.dumps(policy))
10. Common Pitfalls — Clock Skew, CORS, Bucket Policy Conflicts, STS Expiry
Even with the patterns above implemented correctly, presigned URLs fail in production for a handful of recurring reasons. Knowing the failure modes in advance saves hours of debugging.
Clock Skew — The Silent Killer
SigV4 signatures include a timestamp and are only valid within ±15 minutes of the signing time on AWS's clock. If the machine generating the presigned URL has a clock that is more than 15 minutes off from AWS, every URL will return 403 immediately. This is especially common in Docker containers, VMs that were hibernated, and servers with misconfigured NTP. Fix: ensure NTP is running and synchronized (timedatectl status on Linux), or use chronyc tracking to verify offset.
# Check NTP sync status on Amazon Linux / Ubuntu
timedatectl status
# Force NTP sync
sudo systemctl restart chronyd # Amazon Linux
sudo systemctl restart systemd-timesyncd # Ubuntu
# Docker: host clock skew affects all containers
# On macOS Docker Desktop: restart Docker or run:
docker run --rm busybox date
CORS Configuration for Browser Uploads
Browser-side PUT requests to S3 trigger a CORS preflight (OPTIONS request). Without a CORS configuration on the bucket, the browser will block the upload before it even starts. The error looks like "Access to fetch at 'https://...' from origin '...' has been blocked by CORS policy."
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "PUT", "HEAD"],
"AllowedOrigins": ["https://app.yourcompany.com"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}
]
aws s3api put-bucket-cors \
--bucket my-upload-bucket \
--cors-configuration file://cors-config.json
ExposeHeaders. For multipart uploads, your JavaScript needs to read the ETag header from each PUT response to complete the upload. Without "ETag" in ExposeHeaders, response.headers.get('ETag') returns null and the multipart completion call fails.
Bucket Policy Conflicts with Presigned URLs
Bucket policies with explicit Deny statements override the presigned URL's implicit Allow. The most common conflict is a "Deny if no SSE-KMS" policy on a bucket where you are generating presigned URLs without specifying the KMS key. The URL is valid but S3 rejects the PUT because the object is not encrypted with the required KMS key.
# If the bucket requires SSE-KMS, include it in the Params
url = s3_client.generate_presigned_url(
'put_object',
Params={
'Bucket': bucket,
'Key': key,
'ContentType': content_type,
'ServerSideEncryption': 'aws:kms',
'SSEKMSKeyId': 'arn:aws:kms:us-east-1:123456789012:key/mrk-abc123',
},
ExpiresIn=900,
)
STS Session Token Expiry Before URL Expiry
When your Lambda function or EC2 instance role generates a presigned URL, the underlying STS session has its own expiry that is independent of the URL's X-Amz-Expires parameter. If the STS session expires at T+60min but you generated a presigned URL with ExpiresIn=86400 (24 hours), the URL becomes unusable when the STS session expires at T+60min — not at T+24h. The workaround is to either sign with IAM user credentials (long-term, no STS session) or ensure the generated URL's expiry is shorter than the STS session duration.
import boto3
# WRONG: Lambda's instance credentials may expire in 60 min
# but you are generating a 24-hour URL
s3 = boto3.client('s3') # uses Lambda's temporary role credentials
url = s3.generate_presigned_url('get_object', Params={...}, ExpiresIn=86400) # WILL fail at STS expiry
# RIGHT: Use explicit long-term IAM credentials from Secrets Manager for long-lived URLs
import json
sm = boto3.client('secretsmanager')
secret = json.loads(sm.get_secret_value(SecretId='s3-presign-iam-user')['SecretString'])
s3_long = boto3.client(
's3',
aws_access_key_id=secret['access_key_id'],
aws_secret_access_key=secret['secret_access_key'],
region_name='us-east-1',
)
url = s3_long.generate_presigned_url('get_object', Params={...}, ExpiresIn=86400) # safe