Upload path on dark navy: browser, signed URL, private bucket, scan, then a time-limited read
S3file uploadssigned URLssecurityAWScloud

Secure File Uploads to S3

Do not put a public bucket in front of users. Content types, size limits, virus scanning, and signed URLs - the upload path we use in production.

Swati ChaudharyFounder, Myrafy

A public S3 bucket with GetObject for the world is not a product feature. It is an incident waiting for a crawler. Secure file uploads mean the browser never gets a long-lived key, the object never stays world-readable, and what you stored is what you thought you stored.

This is the path we use on cloud and product work. It pairs with stop committing secrets (the key never ships to the client) and a boring AWS baseline (private subnets, alarms, one bucket naming scheme).

Private Bucket, Signed Everything

Upload flow: app mints a PUT URL, client uploads, scanner, then a short GET URL for read
The bucket policy should deny public access even if an ACL is wrong

Defaults:

  • Block Public Access on the bucket (all four settings)
  • Encryption at rest (SSE-S3 or KMS if you already have a key policy)
  • Versioning on if deletes would hurt; lifecycle rules so incomplete uploads die
  • Access via presigned URLs or CloudFront with signed cookies - not public-read ACLs

The API that mints the URL is authenticated like the rest of the product (JWT / session rules still apply). The URL includes:

  • Key prefix the user is allowed to write (uploads/${tenantId}/${userId}/...)
  • Content-Type the server allowed, not whatever the browser claimed
  • Max size (content-length-range in a POST policy, or a checked Content-Length on PUT)
  • Expiry measured in minutes, not days

Do not mint a URL for ../ or a key outside the tenant prefix. Treat the key as an authorization decision.

Content Types and Size Are Product Rules

"Allow all" is how you store HTML that gets served as a user profile and turns into XSS, or a 5GB "avatar."

Allow-list by purpose:

Purpose Allow Max size (start here)
Avatar jpeg, png, webp 2-5 MB
Document pdf, and maybe docx if you must 10-25 MB
Video a transcode pipeline, not raw dump to the bucket defined by the encoder

Sniff magic bytes after the upload, on a worker, before the object is readable by other users. Content-Type on the wire is a claim.

Serve downloads as Content-Disposition: attachment unless you have a dedicated, locked-down preview origin. Never serve user HTML from your cookie domain.

Scan Before Anyone Else Reads It

Presigned PUT lands in a quarantine prefix or a holding bucket. A worker (Lambda, ECS task, or queue consumer) runs:

  • Size and magic-byte check
  • Malware scan (ClamAV, a vendor API, or your cloud's native scanner)
  • Optional: image re-encode so the bytes you store are not the bytes they sent (strips weird payloads)

Only then copy or move to the clean prefix and write the metadata in Postgres. The app never points users at quarantine.

If you skip the scan because "it is only internal," you will paste a link in Slack. Internal is still a distribution channel.

What Not to Do

  • Access keys in the mobile app or a public git repo
  • PutObject with ACL: public-read
  • Using the same bucket for logs, Terraform state, and user avatars
  • Infinite-expiry signed URLs in emails
  • Trusting the filename extension
  • Turning the bucket into a static host for untrusted HTML

Website hosting on S3 is fine for your compiled assets with a tight origin. It is not a user-upload product.

Observability and Cost

Log tenant_id, key prefix, bytes, and who minted the URL. Alert on a sudden spike in PUT count - that is a loop or an abused token, the same class of problem as rate limits.

Incomplete multipart uploads linger on the bill. A lifecycle rule for abort incomplete after a day is not optional.

FAQ

Can the client upload straight to S3? Yes, that is what presigned URLs are for. The authorization still lives in your API. Direct-to-S3 without your signature is just a public inbox.

Is CloudFront required? No for an MVP with low traffic. Yes when you need a custom domain, TLS you control, geo caching, or to keep the bucket fully private with origin access.

What about Azure Blob or GCS? Same shape: private store, short-lived write token, scan, short-lived read. The brand of object storage is not the security model.

The Bottom Line

Secure file uploads to S3 are a private bucket, a short signed write, a scan, and a short signed read. Public ACLs are not a shortcut. They are the incident.

Put the allow-list and the prefix rule in the SOW. Then the "upload feature" is software, not a bucket with hope.


Need uploads designed as part of the build, not bolted on in week eight? See cloud and DevOps or get a free estimate.

Contact us