S3 Security Mistakes That Are Costing You Money (And Sleep) πΈπ
S3 Security Mistakes That Are Costing You Money (And Sleep) πΈπ
Real talk: The first time I deployed an app with S3, I was so proud. "Look at me, using cloud storage like a pro!" Then three things happened:
- My AWS bill was $847 instead of $40
- Security scanned our infra and found a publicly accessible bucket with user data
- I learned that S3 has more gotchas than a horror movie
S3 is deceptively simple - it's "just storage" until it's NOT. Let me save you from the expensive, embarrassing lessons I learned! π
What Even Is S3? (Beyond "Cloud Storage") βοΈ
S3 = Simple Storage Service - AWS's object storage that stores literally TRILLIONS of objects.
Think of it like: Dropbox meets a database meets a CDN... but with 47 configuration options that can bankrupt you or expose your data if you mess up!
Real usage:
- Storing user uploads (images, videos, documents)
- Static website hosting
- Database backups
- Data lakes and analytics
- CDN origin for CloudFront
Why it's everywhere: Dirt cheap (when configured right), infinitely scalable, 99.999999999% durability (11 nines!)
Why it's dangerous: Easy to misconfigure, costs can explode, public buckets are a security nightmare!
The $847 AWS Bill: My S3 Horror Story π
When architecting our e-commerce backend at my previous company, I used S3 to store product images. Simple enough, right?
What I did (naively):
# Created bucket
aws s3 mb s3://my-product-images
# Uploaded images
aws s3 cp ./images s3://my-product-images --recursive
# Made them public (so users can view them)
aws s3api put-bucket-acl --bucket my-product-images --acl public-read
What happened next:
- Google indexed our bucket (oops, no robots.txt)
- Scrapers downloaded EVERYTHING repeatedly
- Data transfer costs exploded ($0.09/GB adds up FAST)
- Storage costs ballooned (kept every version of every file)
- Boss saw the bill: "WTF happened?!"
- Me: updates LinkedIn π¬
The breakdown:
- Storage: 500GB Γ $0.023/GB = $11.50 β Reasonable
- Requests: 10M GET requests Γ $0.0004/1000 = $4 β Fine
- Data Transfer: 8TB Γ $0.09/GB = $737.28 π±π±π±
- Version Storage: 200GB old versions Γ $0.023/GB = $4.60
- Total: $757.38 for what should've been a $20 bill!
The lesson: S3 pricing has LAYERS. Storage is cheap. Transfer is NOT!
S3 Security Mistake #1: Accidentally Public Buckets π¨
The most common S3 disaster:
# DON'T DO THIS (unless you REALLY mean it)
aws s3api put-bucket-acl --bucket my-bucket --acl public-read
# Now ANYONE on the internet can list and download your files!
# https://my-bucket.s3.amazonaws.com/ shows EVERYTHING
Real examples of public bucket leaks:
- Capital One (2019): 100M customer records exposed
- Facebook (2019): 540M user records
- Verizon (2017): 14M customer records
- Me (2021): User profile pictures AND original filenames with PII π€¦ββοΈ
How I discovered my mistake:
# Security scan showed:
# "Bucket 'user-uploads-prod' is publicly accessible!"
# Checked it:
curl https://user-uploads-prod.s3.amazonaws.com/
# Returned XML listing of ALL files. Including:
# - "john-smith-drivers-license.jpg"
# - "internal-financials-2021.pdf"
# - "passwords-backup.txt" (WHY WAS THIS IN S3?!)
The proper fix:
# 1. Block ALL public access (use this by default!)
aws s3api put-public-access-block \
--bucket my-bucket \
--public-access-block-configuration \
"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
# 2. Remove public ACLs
aws s3api put-bucket-acl --bucket my-bucket --acl private
# 3. Use pre-signed URLs for temporary access
aws s3 presign s3://my-bucket/file.jpg --expires-in 3600
# Generates temporary URL valid for 1 hour
In production, I've deployed S3 buckets with CloudFront in front - files are private in S3, but served publicly via CDN with proper caching. Best of both worlds! π―
S3 Security Mistake #2: Missing Encryption π
What I didn't know: S3 doesn't encrypt by default (well, it does NOW, but didn't in 2020!)
The naive approach:
# Just upload files
aws s3 cp sensitive-data.csv s3://my-bucket/
# Stored in plaintext on AWS servers!
What happens:
- Data is transmitted encrypted (HTTPS)
- BUT stored unencrypted on disk
- Compliance violations (GDPR, HIPAA, etc.)
- If AWS has a breach, your data is readable
The proper approach - Server-Side Encryption (SSE):
Option 1: SSE-S3 (AWS-managed keys, easiest)
# Enable default encryption on bucket
aws s3api put-bucket-encryption \
--bucket my-bucket \
--server-side-encryption-configuration '{
"Rules": [{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "AES256"
}
}]
}'
# Now all uploads are encrypted automatically!
Option 2: SSE-KMS (Your own keys, more control)
# Create KMS key
aws kms create-key --description "S3 encryption key"
# Enable KMS encryption
aws s3api put-bucket-encryption \
--bucket my-bucket \
--server-side-encryption-configuration '{
"Rules": [{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:us-east-1:123456789:key/xyz"
}
}]
}'
Option 3: Client-Side Encryption (Encrypt before upload)
const AWS = require('aws-sdk');
const s3 = new AWS.S3();
const crypto = require('crypto');
// Encrypt file before uploading
function uploadEncrypted(filePath, bucketKey) {
const fileContent = fs.readFileSync(filePath);
const cipher = crypto.createCipher('aes-256-cbc', process.env.ENCRYPTION_KEY);
let encrypted = cipher.update(fileContent);
encrypted = Buffer.concat([encrypted, cipher.final()]);
return s3.upload({
Bucket: 'my-bucket',
Key: bucketKey,
Body: encrypted
}).promise();
}
My production setup:
- User uploads (images, docs): SSE-S3 (simple, effective)
- Financial data, PII: SSE-KMS (audit trail, key rotation)
- Super sensitive stuff: Client-side encryption + SSE-KMS (paranoid mode!)
A serverless pattern that saved us: Enable bucket-wide encryption by default. Can't forget to encrypt if it's automatic! π
S3 Mistake #3: Not Using Lifecycle Policies (Money Drain) πΈ
The problem:
# Upload a file
aws s3 cp large-video.mp4 s3://my-bucket/
# A year later, the file is STILL there
# Costing $0.023/GB/month in S3 Standard
# For a 10GB file: $0.23/month = $2.76/year FOREVER
Multiply by thousands of files? Your storage costs never stop growing!
The solution - Lifecycle Policies:
{
"Rules": [
{
"Id": "Move old files to cheaper storage",
"Status": "Enabled",
"Transitions": [
{
"Days": 30,
"StorageClass": "STANDARD_IA"
},
{
"Days": 90,
"StorageClass": "GLACIER"
}
],
"Expiration": {
"Days": 365
}
}
]
}
What this does:
- Day 0-30: S3 Standard ($0.023/GB) - fast access
- Day 30-90: S3 IA - Infrequent Access ($0.0125/GB) - 45% cheaper!
- Day 90-365: Glacier ($0.004/GB) - 82% cheaper!
- Day 365+: Deleted automatically
Real savings from our production setup:
Before lifecycle policies:
- 5TB storage in S3 Standard
- Cost: 5000GB Γ $0.023 = $115/month
After lifecycle policies:
- 500GB in S3 Standard (recent files)
- 2TB in S3 IA (30-90 days old)
- 2.5TB in Glacier (90+ days old)
- Cost: (500 Γ $0.023) + (2000 Γ $0.0125) + (2500 Γ $0.004)
- Cost: $11.50 + $25 + $10 = $46.50/month
- **Savings: $68.50/month = $822/year** π
Apply lifecycle policy:
aws s3api put-bucket-lifecycle-configuration \
--bucket my-bucket \
--lifecycle-configuration file://lifecycle.json
When architecting on AWS, I learned: Most data is only accessed frequently for a few weeks. Lifecycle policies are FREE money! π°
S3 Mistake #4: Versioning Without Lifecycle = Storage Bomb π£
S3 Versioning sounds great: "Never lose data! Every version is saved!"
The reality:
# Enable versioning
aws s3api put-bucket-versioning \
--bucket my-bucket \
--versioning-configuration Status=Enabled
# User uploads profile.jpg (10MB)
# User updates profile.jpg (10MB)
# User updates again (10MB)
# User updates again (10MB)
# Storage used: 40MB (all 4 versions kept!)
# Cost: 4Γ what you expected
My wake-up call:
# Check bucket size
aws s3 ls s3://my-bucket --recursive --summarize
# Expected: 200GB
# Actual: 1.2TB (!)
# Reason: 6 months of versions piled up
The fix - Lifecycle policy for old versions:
{
"Rules": [
{
"Id": "Delete old versions",
"Status": "Enabled",
"NoncurrentVersionExpiration": {
"NoncurrentDays": 30
}
}
]
}
Translation: Keep current version forever, delete non-current versions after 30 days!
Even better - Transition old versions to cheaper storage:
{
"Rules": [
{
"Id": "Optimize old versions",
"Status": "Enabled",
"NoncurrentVersionTransitions": [
{
"NoncurrentDays": 30,
"StorageClass": "GLACIER"
}
],
"NoncurrentVersionExpiration": {
"NoncurrentDays": 90
}
}
]
}
Result: Old versions move to Glacier (cheap) after 30 days, deleted after 90 days. Best of both worlds!
S3 Mistake #5: Not Using CloudFront (Bandwidth Costs) π‘
The problem:
User in Europe β S3 bucket in us-east-1 (Virginia)
- Latency: 200ms
- Data transfer: $0.09/GB (expensive!)
The solution - CloudFront CDN:
User in Europe β CloudFront Edge (Frankfurt) β S3 (once)
- Latency: 20ms (10Γ faster!)
- Data transfer: $0.085/GB (slightly cheaper)
- Cache hit ratio: 90% (only 10% of requests hit S3!)
Setup CloudFront distribution:
aws cloudfront create-distribution \
--origin-domain-name my-bucket.s3.amazonaws.com \
--default-root-object index.html
Real results from our e-commerce site:
- Before CloudFront: 8TB/month S3 data transfer = $720
- After CloudFront: 800GB S3 transfer + 8TB CloudFront = $72 + $680 = $752
- Wait, that's more expensive?! π€
BUT - with caching:
- CloudFront cache hit ratio: 92%
- Actual S3 transfer: 640GB (8% of traffic)
- Cost: (640GB Γ $0.09) + (8TB Γ $0.085) = $57.60 + $680 = $737.60
- Plus: Way faster for users worldwide! π
The real win: Performance AND (eventually) cost savings!
Pro tip: Set cache TTL to 1 year for static assets:
// Upload with cache headers
aws s3 cp file.jpg s3://my-bucket/ \
--cache-control "max-age=31536000, public"
S3 Mistake #6: Wrong Access Control Strategy π
The confusion: S3 has FOUR ways to control access:
- Bucket policies (resource-based)
- IAM policies (user-based)
- ACLs (legacy, don't use!)
- Pre-signed URLs (temporary access)
Which to use? Here's my production playbook:
Use Case 1: Public Static Website
// Bucket policy for public read
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-public-site/*"
}]
}
Use Case 2: Private User Uploads
// Generate pre-signed URL (Node.js)
const AWS = require('aws-sdk');
const s3 = new AWS.S3();
function getUploadUrl(userId, fileName) {
return s3.getSignedUrl('putObject', {
Bucket: 'user-uploads',
Key: `${userId}/${fileName}`,
Expires: 300, // 5 minutes
ContentType: 'image/jpeg'
});
}
// Frontend uploads directly to S3 with this URL
// No need to go through your server!
Use Case 3: Cross-Account Access
// Bucket policy allowing another AWS account
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789:root"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::shared-bucket/*"
}]
}
Use Case 4: Lambda Function Access
// IAM role for Lambda
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject"
],
"Resource": "arn:aws:s3:::my-bucket/*"
}]
}
My rule: Use bucket policies for bucket-wide rules, IAM policies for user/role permissions, pre-signed URLs for temporary access!
S3 Mistake #7: Not Monitoring Access (Security Blind Spot) ποΈ
What I learned the hard way: S3 access logs are OFF by default!
Enable S3 access logging:
aws s3api put-bucket-logging \
--bucket my-bucket \
--bucket-logging-status '{
"LoggingEnabled": {
"TargetBucket": "my-logs-bucket",
"TargetPrefix": "s3-access-logs/"
}
}'
Even better - Enable CloudTrail for API calls:
aws cloudtrail create-trail \
--name s3-audit \
--s3-bucket-name my-audit-logs
aws cloudtrail put-event-selectors \
--trail-name s3-audit \
--event-selectors '[{
"ReadWriteType": "All",
"IncludeManagementEvents": true,
"DataResources": [{
"Type": "AWS::S3::Object",
"Values": ["arn:aws:s3:::my-bucket/*"]
}]
}]'
What you can detect:
- Suspicious access patterns (brute force attacks)
- Data exfiltration (unusual download volumes)
- Unauthorized access attempts
- Deletions (accidental or malicious)
A production incident this caught for us:
CloudTrail Alert: 10,000 GetObject requests from unknown IP
Investigation: Scrapers found our bucket
Fix: Enabled CloudFront + restricted S3 to CloudFront only
S3 Mistake #8: Missing Bucket Policies for Least Privilege π
Bad practice:
// Giving Lambda FULL S3 access (too permissive!)
{
"Effect": "Allow",
"Action": "s3:*",
"Resource": "*"
}
Good practice - Least privilege:
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject"
],
"Resource": "arn:aws:s3:::my-specific-bucket/uploads/*"
}
Even better - Restrict by condition:
{
"Effect": "Allow",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::user-uploads/*",
"Condition": {
"StringEquals": {
"s3:x-amz-server-side-encryption": "AES256"
},
"NumericLessThan": {
"s3:content-length": 10485760
}
}
}
Translation: Can only upload encrypted files smaller than 10MB. Perfect for user profile images! πΈ
The S3 Cost Optimization Checklist π°
Here's how I reduced our S3 bill by 78%:
1. Enable Intelligent-Tiering
# Automatically moves objects to cost-effective storage
aws s3api put-bucket-intelligent-tiering-configuration \
--bucket my-bucket \
--id "OptimizeCosts" \
--intelligent-tiering-configuration '{
"Id": "OptimizeCosts",
"Status": "Enabled",
"Tierings": [{
"Days": 90,
"AccessTier": "ARCHIVE_ACCESS"
}]
}'
Result: 40-50% storage cost reduction automatically!
2. Delete Incomplete Multipart Uploads
# These cost money but serve no purpose!
aws s3api put-bucket-lifecycle-configuration \
--bucket my-bucket \
--lifecycle-configuration '{
"Rules": [{
"Id": "DeleteIncompleteUploads",
"Status": "Enabled",
"AbortIncompleteMultipartUpload": {
"DaysAfterInitiation": 7
}
}]
}'
I discovered: We had 80GB of incomplete uploads costing $2/month for nothing!
3. Use S3 Batch Operations for Cleanup
# Delete old files matching pattern
aws s3 rm s3://my-bucket/temp/ --recursive --exclude "*" --include "*.tmp"
4. Compress Before Uploading
// Compress images before S3
const sharp = require('sharp');
async function uploadOptimized(filePath, key) {
const compressed = await sharp(filePath)
.jpeg({ quality: 85 })
.resize(1200, null, { withoutEnlargement: true })
.toBuffer();
return s3.upload({
Bucket: 'my-bucket',
Key: key,
Body: compressed,
ContentType: 'image/jpeg'
}).promise();
}
Savings: 70% smaller files = 70% lower storage + transfer costs!
5. Requester Pays (For Shared Data)
# Make downloaders pay for data transfer
aws s3api put-bucket-request-payment \
--bucket public-datasets \
--request-payment-configuration '{"Payer":"Requester"}'
Use case: Sharing large datasets publicly without paying for bandwidth!
The S3 Security Checklist π‘οΈ
Before going to production:
- Block all public access (unless you REALLY need it)
- Enable default encryption (SSE-S3 minimum)
- Enable versioning (with lifecycle cleanup!)
- Set up lifecycle policies (transition + expiration)
- Enable access logging (know who's accessing what)
- Enable CloudTrail (audit API calls)
- Use pre-signed URLs (not public ACLs)
- Restrict by IAM policies (least privilege)
- Enable MFA delete (for critical buckets)
- Set up CloudFront (better security + performance)
- Monitor costs (set billing alerts!)
Common S3 Patterns I Use in Production π―
Pattern 1: Direct Browser Upload (Bypass Server)
// Backend generates pre-signed POST policy
function generateUploadPolicy(userId, fileName) {
const params = {
Bucket: 'user-uploads',
Fields: {
key: `${userId}/${Date.now()}-${fileName}`,
'Content-Type': 'image/jpeg'
},
Conditions: [
['content-length-range', 0, 5242880], // Max 5MB
['starts-with', '$Content-Type', 'image/']
],
Expires: 300
};
return s3.createPresignedPost(params);
}
// Frontend uploads directly to S3
// No server bandwidth used! π
Pattern 2: S3 Event Triggers Lambda
// Auto-process uploaded images
exports.handler = async (event) => {
const bucket = event.Records[0].s3.bucket.name;
const key = event.Records[0].s3.object.key;
// Download from S3
const image = await s3.getObject({ Bucket: bucket, Key: key }).promise();
// Process (resize, watermark, etc.)
const processed = await processImage(image.Body);
// Upload processed version
await s3.putObject({
Bucket: bucket,
Key: key.replace('/uploads/', '/processed/'),
Body: processed
}).promise();
};
Pattern 3: S3 as Static Site + CloudFront
# Enable static website hosting
aws s3 website s3://my-site \
--index-document index.html \
--error-document 404.html
# Point CloudFront to S3 website endpoint
# Result: Fast, cheap, scalable website! π
The Bottom Line π‘
S3 is incredible - but only when configured right!
The essentials:
- Security first: Block public access, encrypt everything, use pre-signed URLs
- Lifecycle policies: Don't pay for data you don't need
- CloudFront: Cache at the edge, save bandwidth
- Monitor everything: Logs, costs, access patterns
- Least privilege: IAM policies, bucket policies, conditions
The truth about S3:
It's not "just storage" - it's a powerful service with complex pricing and security implications. One wrong configuration can leak data OR drain your wallet!
When architecting our e-commerce backend, I learned: S3 is dirt cheap when optimized, terrifyingly expensive when misconfigured. Enable encryption by default. Use lifecycle policies from day one. Monitor like a hawk. And for the love of all that is holy, NEVER make buckets public unless you absolutely must! π
You don't need perfect S3 configuration from day one - you need SECURE defaults that evolve with your needs! π
Your Action Plan π―
This week:
- Audit ALL S3 buckets (check public access!)
- Enable default encryption on every bucket
- Set up lifecycle policies for old data
- Enable access logging and CloudTrail
This month:
- Implement CloudFront for public assets
- Add pre-signed URLs for user uploads
- Review IAM policies (least privilege!)
- Set up cost alerts and monitoring
This quarter:
- Optimize storage classes (save $$$)
- Implement S3 Intelligent-Tiering
- Clean up old versions and incomplete uploads
- Become the S3 security guru on your team! π
Resources Worth Your Time π
Tools I use daily:
- S3 Browser - GUI for S3 management
- CloudBerry Explorer - Advanced S3 features
- S3 Cost Calculator - Estimate costs before deploying
Reading list:
Real talk: The best S3 strategy is secure by default, cheap by design, monitored religiously!
Still paying too much for S3? Connect with me on LinkedIn and share your AWS cost optimization wins!
Want to see my serverless architectures? Check out my GitHub - I've built entire backends on S3 + Lambda!
Now go forth and secure those buckets! πβοΈ
P.S. If you've never checked your S3 buckets for public access, do that RIGHT NOW. I'll wait. Seriously, go check. I've seen too many data breaches start with "Oops, I didn't know that bucket was public!" π¨
P.P.S. I once spent $400 on data transfer because I forgot CloudFront invalidation costs money. Moral: Read the pricing docs BEFORE clicking deploy! πΈ