A file upload endpoint is the one place in a SaaS product where you accept arbitrary bytes from the internet, store them indefinitely and later serve them back to other users, often under your own domain and inside an authenticated session. Most implementations I review treat it as a form field. That design ships stored XSS through SVGs, malware delivered to other tenants, public buckets indexed by search engines and invoices readable by anyone who can guess an ID. Secure uploads are an architecture decision, not a validation rule.
At Loop Origin we build document-heavy B2B portals, from KYC onboarding to dealer ordering, and upload handling is part of every custom web application we design. This is the reference architecture I use, with production configuration for Node and Laravel, the cost trade-offs and the failure modes behind each control.
What is a secure file upload architecture for a SaaS app?
Direct answer: Upload directly to private object storage with short-lived presigned URLs that enforce size and type, land files in a quarantine bucket, scan and validate them asynchronously by content rather than extension, re-encode images, then promote clean files to a private bucket and serve them through short-lived signed URLs from a separate domain with download-safe headers.
The threat model
| Threat | Concrete example | Primary control | Layer |
|---|---|---|---|
| Stored XSS | SVG or HTML with script, served inline from app.example.com
|
Separate content domain, Content-Disposition: attachment, nosniff, CSP sandbox
|
Delivery |
| Content-type spoofing |
invoice.pdf that is actually HTML |
Magic-byte detection, ignore client MIME type | Validation |
| Malware distribution | Infected document shared with another tenant's users | Antivirus scan before the file becomes downloadable | Processing |
| Parser exploits | Malformed image hitting a vulnerable image library | Re-encode in an isolated worker, patched libraries, size limits | Processing |
| Decompression bombs | Small archive that expands to gigabytes | Reject archives or cap expanded size and file count | Validation |
| Broken access control |
/files/1043 readable by another tenant |
Per-request authorization, unguessable keys, short-lived URLs | Delivery |
| Path traversal | Filename ../../.env
|
Never use client filenames as storage keys | Storage |
| Metadata leaks | GPS coordinates in a photo | Strip metadata on re-encode | Processing |
| Storage abuse | Scripted uploads to run up your bill | Per-tenant quotas, rate limits, lifecycle expiry | Storage |
The metadata row is easy to underestimate; I showed what a single phone photo leaks in image metadata privacy leaks.
The reference flow
Browser ──(1) request upload──► API: authorize, create upload record, presign POST
Browser ──(2) POST file───────► quarantine bucket (private, expires after 7 days)
Storage ──(3) event───────────► queue ──► scan worker (isolated, no inbound network)
Worker: magic bytes â–º size/structure â–º antivirus â–º re-encode images â–º strip metadata
Worker ──(4) clean────────────► files bucket (private) + mark record "ready"
Worker ──(4) rejected─────────► delete + mark record "rejected" + alert
Browser ──(5) download────────► API: authorize per request ► 5-minute signed GET
served from usercontent.example.net
Files never touch the app servers, and nothing is downloadable until the worker marks it ready.
Step 1: Presigned uploads that enforce limits
A presigned PUT URL only proves the client may write one key. A presigned POST policy can also enforce size and content type at the storage layer. In Node with AWS SDK v3:
import { S3Client } from '@aws-sdk/client-s3';
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
import { randomUUID } from 'node:crypto';
const s3 = new S3Client({ region: process.env.AWS_REGION });
const ALLOWED = new Set(['application/pdf', 'image/jpeg', 'image/png']);
export async function presignUpload({ tenantId, userId, contentType, size }) {
if (!ALLOWED.has(contentType)) throw new Error('type_not_allowed');
if (size > 15 * 1024 * 1024) throw new Error('too_large');
const key = `quarantine/${tenantId}/${randomUUID()}`; // never the client filename
const { url, fields } = await createPresignedPost(s3, {
Bucket: process.env.UPLOAD_BUCKET,
Key: key,
Conditions: [
['content-length-range', 1, 15 * 1024 * 1024],
['eq', '$Content-Type', contentType],
],
Fields: { 'Content-Type': contentType },
Expires: 300,
});
await db.uploads.insert({ key, tenantId, userId, status: 'pending', declaredType: contentType });
return { url, fields, key };
}
In Laravel 10 and later, Storage::disk('s3')->temporaryUploadUrl($key, now()->addMinutes(5)) returns a presigned PUT URL and headers. It is simpler, but enforce size with a bucket-side check in the worker because a PUT URL cannot carry a size range. For uploads that go through the app, validate with mimetypes: (content-based) rather than mimes: alone, and set max: in kilobytes.
Step 2: Validate by content, then scan
The scan worker runs on a separate small instance or container with no inbound network access and read access only to the quarantine prefix. Its pipeline, in order of cost:
# 1. Real type from magic bytes, not the extension
type=$(file --mime-type -b "$f")
case "$type" in application/pdf|image/jpeg|image/png) ;; *) reject "type:$type";; esac
# 2. Antivirus with ClamAV's daemon (exit 0 clean, 1 infected, 2 error)
clamdscan --fdpass --no-summary "$f"; rc=$?
[ $rc -eq 1 ] && reject "malware"
[ $rc -eq 2 ] && retry_later "scanner_error" # fail closed, never mark ready
Treat a scanner error as "not clean". The most common scanning incident I have seen was a worker that marked files ready whenever the scanner timed out.
For images, re-encode instead of trusting the original: decode, resize to a maximum dimension, and write a fresh JPEG or WebP with metadata stripped. This removes EXIF data, appended polyglot payloads and most malformed structures in one step; run it with resource limits.
Antivirus signatures catch known malware, not targeted payloads, which is why content validation, re-encoding and delivery isolation still matter.
Step 3: Serve files so they cannot attack your app
A clean file is still dangerous if the browser renders it on your app's origin. My delivery defaults:
Content-Disposition: attachment; filename="invoice-2026-10.pdf"
Content-Type: application/pdf (the validated type, not the uploaded one)
X-Content-Type-Options: nosniff
Content-Security-Policy: sandbox; default-src 'none'
Cache-Control: private, max-age=300
Serve from a separate registrable domain, such as example-usercontent.net, so that a file that does execute script has no access to your app's cookies or same-origin APIs. A subdomain of your main domain is weaker, because cookies scoped to the parent domain still travel; I covered that in cookie security flags.
Every download goes through an API endpoint that checks the tenant and the user's permission on that record, then issues a signed URL valid for a few minutes. Signed URLs are bearer tokens: anyone holding one can use it until it expires, so keep the lifetime short and never log full URLs. In shared-schema multi-tenant systems, scope the lookup by tenant in the query itself, as discussed in multi-tenant SaaS database architecture.
Step 4: Lock down the buckets
- Block all public access at the account and bucket level.
- Deny requests that are not over TLS with a bucket policy condition on
aws:SecureTransport. - Enable default encryption; use a customer-managed key for regulated documents so access is auditable.
- Add a lifecycle rule that expires everything under
quarantine/after a few days, so abandoned and rejected uploads do not accumulate.
Cost and trade-offs
| Decision | Option A | Option B | Cost drivers | When I choose B |
|---|---|---|---|---|
| Upload path | Through app servers | Direct presigned to storage | A: app CPU, memory and timeouts; B: almost none | Almost always |
| Scan timing | Synchronous in request | Asynchronous worker | A: slow uploads and timeouts; B: one queue and worker | Files above a few MB or any volume |
| Scanner | Self-hosted ClamAV | Managed malware scanning from the cloud provider | A: a worker with enough memory for signatures, plus patching; B: per-GB pricing | Low ops capacity or compliance needs |
| Delivery domain | App subdomain | Separate registrable domain | B: one extra domain and TLS certificate | Any product where users upload for other users |
| Image handling | Store original | Re-encode and strip | B: worker CPU per image | User-generated images shown to others |
ClamAV keeps signatures in memory; do not co-locate it on a small app server.
Failure modes I have seen
- Bucket made public "temporarily" to fix broken image previews, then forgotten.
- Download URLs with year-long expiry pasted into emails and support tickets.
-
Extension allowlist only, bypassed with
report.pdf.htmlor a correct extension on the wrong content. - Scanner fail-open on timeouts, as described above.
- Thumbnails generated before scanning, running untrusted files through the image library first.
Implementation checklist
- Presigned POST with size and type conditions; random keys under a tenant prefix.
- Quarantine prefix with lifecycle expiry; nothing downloadable until marked ready.
- Isolated worker: magic-byte check, structure and size limits, antivirus, fail closed.
- Re-encode images and strip metadata; reject or tightly cap archives.
- Private buckets, TLS-only policy, encryption at rest.
- Separate content domain with
attachment,nosniffand CSPsandbox. - Per-request authorization and signed GET URLs measured in minutes.
- Per-tenant quotas, upload rate limits and alerts on rejection spikes.
- Audit log entries for upload, scan result, download and deletion.
Need this reviewed on an existing platform? We cover it in server setup and maintenance engagements and Laravel development work.
Frequently asked questions
Is checking the file extension enough?
No. Extensions and the browser-supplied MIME type are both controlled by the uploader. Detect the type from the file's magic bytes on the server, compare it with your allowlist, and serve it with the validated type and nosniff.
Is ClamAV good enough for scanning uploads?
It catches known malware signatures and is a reasonable baseline for document uploads. It will not catch targeted or novel payloads, so combine it with content validation, re-encoding and safe delivery headers rather than relying on it alone.
How long should signed download URLs last?
Minutes, not days. Generate a fresh URL on each authorized request; long-lived URLs behave like public links once shared or logged.
Why serve user files from a separate domain?
So a file that executes script runs on an origin with no access to your app's cookies, storage or same-origin APIs.
Are SVG uploads safe to allow?
Not as-is. SVG is XML that can contain script. Either convert SVGs to PNG on upload, sanitize them with a strict allowlist, or serve them only as downloads from the isolated content domain.
Follow & Connect
- Website: Loop Origin
- LinkedIn: Loop Origin
- X: Muhammad Ali Mughal (@AliMughal887)
- GitHub: Loop Origin (@LoopOrigin)

Top comments (0)