File Uploads: Threat Model and Controls
> Design safer upload flows with server-side validation, quarantine scanning, private object storage, secure delivery, and content-type hardening for web apps.
🎧 Listen — ~11 min
Ready · File Uploads: Threat Model and C
Most upload bugs are not inside the file. They are in the pipeline around it: what the browser hints at, what the server trusts, where the bytes land, and how they are later served back to a user. A safe design treats every stage as hostile until it has been checked.
That is the editorial focus here. I am not trying to build a universal allowlist for all possible file types, and I am not making prevalence claims about what attackers prefer. Instead, I am using primary sources to map the concrete controls that matter in production: type validation, size limits, random names, storage isolation, quarantine scanning, and delivery headers. OWASP's file upload guidance and security-header guidance are the main anchors, with MDN and object-storage docs filling in the browser and delivery boundaries.
The short version is simple: the browser can help users select the right file, but only the server can decide whether the bytes are safe to keep, scan, and serve. Everything else is implementation detail.
The upload path is a trust chain
Think about the path in four stages:
- The browser presents a picker and sends bytes.
- The application validates metadata and content.
- The platform stores the upload in quarantine.
- A later step scans, promotes, and serves a sanitized or approved derivative.
Each stage has a different failure mode. The browser can be bypassed. Metadata can be spoofed. Public storage can turn one bad upload into an immediate exploit. Delivery can turn an image upload into script execution if you trust the content type too much.

The screenshot matters because it shows the same controls that show up in real incident writeups: type validation, size limits, filename handling, and where the bytes are stored. It is a reminder that upload safety starts before the bytes ever become user-visible.

The diagram is the architecture I would actually want in production. The point is not that every app needs the same exact services. The point is that validation and delivery are separated, and untrusted bytes do not become public assets until after a deliberate promotion step.
What the browser can do
The browser can improve the user experience, but it is not a security boundary.
1<input
2 type="file"
3 accept=".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp"
4/>That accept attribute is useful because it reduces accidental misuse. It nudges the UI toward the formats your app expects. It does not stop a malicious user, a proxy, or an automation script. MDN documents accept as a hint for file selection, not a guarantee that the submitted data is trustworthy.
The same logic applies to any front-end size check. You can show a friendly error before upload, but the server must still enforce the limit. If you only validate in the browser, an attacker just removes the browser.
So the browser layer should be treated as guidance:
- Helpful for honest users.
- Ignorable by attackers.
- Never the only guardrail.
Server-side validation that survives spoofing
The server needs to validate both the declared metadata and the bytes themselves. OWASP recommends checking type, extension, size, randomizing filenames, and storing files outside the web root. That is the right baseline for a modern app.
1import { randomUUID } from "node:crypto";
2import { mkdir, writeFile } from "node:fs/promises";
3import path from "node:path";
4
5const MAX_FILE_SIZE = 5 * 1024 * 1024;
6const ALLOWED_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp"]);
7const ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
8
9export async function POST(req: Request) {
10 const form = await req.formData();
11 const file = form.get("file");
12
13 if (!(file instanceof File)) {
14 return Response.json({ error: "file is required" }, { status: 400 });
15 }
16
17 if (file.size === 0 || file.size > MAX_FILE_SIZE) {
18 return Response.json({ error: "invalid file size" }, { status: 400 });
19 }
20
21 const ext = path.extname(file.name).toLowerCase();
22 if (!ALLOWED_EXTENSIONS.has(ext) || !ALLOWED_MIME_TYPES.has(file.type)) {
23 return Response.json({ error: "unsupported file type" }, { status: 400 });
24 }
25
26 const buffer = Buffer.from(await file.arrayBuffer());
27 // Production: inspect the magic bytes before promotion. Metadata alone is not enough.
28 const safeName = `${randomUUID()}${ext}`;
29 const quarantineDir = path.join(process.cwd(), "private", "uploads", "quarantine");
30 await mkdir(quarantineDir, { recursive: true });
31 await writeFile(path.join(quarantineDir, safeName), buffer);
32
33 return Response.json(
34 {
35 upload_id: safeName,
36 original_name: file.name.slice(0, 255),
37 mime_type: file.type,
38 status: "quarantined",
39 },
40 { status: 202 },
41 );
42}There are four important details in that example.
First, the original filename is not a storage key. It is metadata at most. If you write ../../something into a public directory, you have handed an attacker path traversal and collision problems for free.
Second, MIME type and extension are both checked. Either one can lie. The combination catches the obvious spoofing cases, but it still does not prove the payload is safe.
Third, the file is written outside the web root. That means there is no accidental https://example.com/uploads/bad.svg path waiting to be discovered before the file has been scanned or transformed.
Fourth, the code returns a quarantine state, not a finished asset. That is a useful API design choice because it keeps the upload and the publish steps separate.
If the business really needs to accept SVG or HTML-like content, the policy gets stricter, not looser. Treat those formats as active content, sanitize them with a trusted pipeline, and serve them from a separate origin with a very careful content policy. Otherwise, reject them.
Quarantine, scan, then promote
Quarantine is the control that turns a dangerous one-step upload into a manageable workflow.
The simplest version looks like this:
- Receive the file.
- Store it in a private quarantine bucket or directory.
- Run malware scanning and type verification.
- If the file passes, promote it to durable storage or generate a safe derivative.
- If the file fails, delete it and keep the audit trail.
That workflow is not just about malware. It also helps with oversized archives, image parsing bugs, and accidental uploads of the wrong file family. If you have ever seen a ZIP bomb, you know why the staging step exists.
| Upload pattern | Security posture | Operational cost | When it fits |
|---|---|---|---|
| Client-side checks only | Weak | Low | Prototypes, never production |
| Server validation + public storage | Better, but still brittle | Low to medium | Simple internal tools |
| Quarantine + scan + private storage | Strong | Medium | Most production apps |
| Quarantine + scan + transform + signed delivery | Strongest | Higher | Public apps, regulated workflows, shared assets |
The table is intentionally opinionated. A public bucket with a direct object URL is convenient, but convenience is not a control. If the upload later gets displayed inline, the platform now has to survive browser quirks, MIME mismatch, and content-sniffing edge cases.
For direct-to-object-storage uploads, the signed URL or signed POST is only an authorization mechanism. It says who may write, not whether the bytes are acceptable. You still need a finalizer step before the file becomes visible.
That separation matches the release-hygiene logic used in other parts of the site, like Secure AI Containers with SBOMs and Provenance. An artifact does not become trustworthy because it was uploaded successfully. It becomes trustworthy after the evidence chain is complete.
Delivery rules that stop upload XSS
The most common delivery mistake is to treat every file as a download, a preview, and a safe inline resource all at once. Those are different modes.
If you are serving a user-supplied file, the default should be attachment, not inline. If a preview is required, generate a safe derivative and serve that derivative from a controlled path.
1function downloadHeaders(filename: string, mimeType: string) {
2 return {
3 "Content-Type": mimeType,
4 "Content-Disposition": `attachment; filename="${filename}"`,
5 "X-Content-Type-Options": "nosniff",
6 "Cache-Control": "private, no-store",
7 };
8}Those headers matter because the browser should not get to reinterpret an uploaded file as something else. OWASP's security-header guidance recommends X-Content-Type-Options: nosniff for exactly this reason. If you omit it and then accidentally serve an HTML or SVG payload from a permissive origin, the browser can turn a storage mistake into script execution.
The safer pattern is:
- Private object storage for originals.
- A server-side preview service for images or PDFs.
attachmentfor raw downloads.- A separate origin or path for anything that can execute code.
That boundary is also why uploads and tool surfaces should be modeled together in threat reviews. If an upload later feeds an MCP tool, a batch processor, or a browser preview, the attack surface has expanded beyond the upload API itself. The same boundary thinking applies in MCP Security Threat Modeling Guide.
What each control buys you
Not every control closes the same risk.
| Control | Stops | Still does not stop |
|---|---|---|
accept attribute | Accidental bad selection | Malicious submissions |
| Size limit | Oversized uploads and simple denial of service | Smuggled payloads under the limit |
| Extension check | Obvious type mismatches | Polyglot files and spoofed bytes |
| MIME check | Some client-side spoofing | Content that lies about itself |
| Magic-byte inspection | Files whose bytes do not match the claim | Valid malicious content |
| Random filename | Traversal and collisions | Unsafe content |
| Quarantine | Immediate public exposure | Later misuse if promotion is careless |
nosniff + attachment | MIME confusion and inline execution | Unsafe derivatives served from a trusted origin |
This is the right mental model for production: no single control is magical, but each one removes a failure mode that the next layer should not have to absorb.
Practical implementation notes
In Next.js or any other backend, keep the route small, validate before storage, and treat the scanner as part of the state machine. Log identifiers and outcomes, not raw content. If you need public media, publish a sanitized derivative rather than the original upload.
This is a useful place to borrow release discipline from container and API work. Structured Outputs for Reliable AI APIs is about schema boundaries, while AI Debugging for Go API Incidents is about production evidence. The upload path wants both: strict structure and a clear audit trail.
Production checklist
Before you expose uploads to real users, verify the following:
- The browser
acceptattribute is only a UX hint. - The server enforces file type, size, and count limits.
- Filenames are random or server-generated.
- Originals are stored outside the web root or in a private bucket.
- Magic-byte inspection or equivalent content verification happens before promotion.
- SVG, HTML, and archives are treated as high-risk formats.
- Public delivery uses
nosniffandattachmentby default. - Preview URLs are separate from original storage URLs.
- Scan failures delete or quarantine the object, not just flag it in a dashboard.
- Logs avoid raw content and include only the metadata needed for incident response.
If one of those items is not true, the upload path is still a risk surface, even if the UI looks polished.
Frequently asked questions
Is the browser accept attribute enough?
No. It improves the file picker experience, but it does not stop a malicious request. Treat it as a UI affordance only. The server still has to validate the bytes.
Should I ever allow SVG uploads?
Only if you have a strong reason and a sanitization pipeline you trust. SVG is not a passive image format in the same way JPEG is. If you do allow it, isolate it, sanitize it, and serve it with much stricter controls than a regular raster image.
Do MIME type checks solve file spoofing?
No. MIME checks help, but they are only one signal. Pair them with extension checks, size limits, and content inspection before promotion. The more valuable rule is that no single metadata field should be trusted on its own.
Why store uploads privately first?
Because public storage turns a validation mistake into an instant exposure. Private quarantine gives your system time to scan, transform, and approve the file before anyone can reach it.
What about antivirus scanning?
Use it, but do not confuse it with a complete safety guarantee. Scanning is a risk-reduction step, not a proof of safety. It should sit inside a broader workflow that also checks type, size, destination, and delivery headers.
How do I prevent ZIP bombs or archive abuse?
Set limits on archive size, entry count, nesting depth, and decompressed size. If the business does not need archives, reject them. If it does, process them in quarantine and fail closed on anything you cannot inspect safely.
Related guides
- MCP Security Threat Modeling Guide
- Secure AI Containers with SBOMs and Provenance
- AI Debugging for Go API Incidents
Methodology and scope
This guide is a primary-source engineering read, not a benchmark report. Confirmed statements come from OWASP's file upload and security-header cheat sheets, MDN's browser input guidance, and object-storage documentation. Anything else, such as attack-path ordering or the choice to quarantine before promotion, is a design inference based on those sources and on standard production practice.
I did not try to measure prevalence, exploit success rates, or the cost of a malware scanner. Those numbers vary too much by application, file family, and deployment. The goal here is to show a defensive architecture that is honest about what the browser can do, what the server must do, and what should happen before a file becomes public.
Sources
- OWASP File Upload Cheat Sheet
- OWASP Security Headers Cheat Sheet
- MDN:
<input type="file">accept attribute - AWS S3 presigned URLs
Author context
I write about AI systems and full-stack engineering from the boundary between architecture and production operations. My preference is to keep the trust boundary visible: browser hints are hints, validation is validation, quarantine is quarantine, and public delivery is the final step, not the first one.
CTA
If you want a review of your upload pipeline, storage policy, or file-serving headers, start with the path that accepts bytes and end with the path that serves them. That is usually where the real bug lives. For direct help, reach out via essamamdani.com/hire.
Keep reading
Related reading
⚡ Daily AI Model Drop — Get Kimi K3 benchmarks before Twitter
Join 2,400+ AI engineers. 1 email/day, no spam, unsubscribe anytime