Insecure file uploads: what goes wrong and how to fix it
Insecure file uploads happen when your upload route believes the browser about a file's name and type, or leaves size checks to the form. Anyone with curl can fake the name and type and skip your form's limits. Check everything on the server, rename files yourself, keep uploads private, and limit presigned URLs (temporary links for uploading straight to your bucket).
QUICK CHECKLIST
- Require login before any upload
- Allowlist types and cap size on the server
- Check the file's first bytes (magic bytes) as well as Content-Type
- Save under a random name in a per-user prefix
- Reject SVG and HTML, or serve them as downloads
- Keep the bucket private, presign short-lived URLs
What insecure file uploads are and why they matter
An upload route takes bytes plus two labels from the browser: a filename and a Content-Type. Insecure file uploads start when your code assumes those labels are true, or checks size only in the form. The client controls both labels.
OWASP, a nonprofit that publishes web security guidance, describes the pattern like this: the first step in many attacks is getting some code onto the system, and a file upload helps the attacker with that step. What happens next depends on what your app does with the file and where it ends up. The range runs from a defaced page and a full disk to a taken-over server.
Code from AI tools often stops once the demo works: accept a file, save it under its original name, return the URL. The checks tend to get added later, if at all.
How attackers use a weak upload route
Everything below is a way a weak route gets abused. Only try these on your own app.
- A fake Content-Type. The header comes from the client. OWASP calls it trivial to spoof, and a web proxy is enough to change it. A check like
file.type.startsWith("image/")only stops honest mistakes. - A tricky extension. Names like
photo.jpg.php,shell.pHpor a null byte after the extension fool naive checks. Validate the whole name after decoding it, against an allowlist. - Path traversal in the filename. If you join
file.nameonto a folder,../../can send the file somewhere else or overwrite one that already exists. OWASP files this under metadata problems. - SVG upload XSS (cross-site scripting: an attacker's script runs on your site). SVG is XML and can carry a script tag. If someone opens the file's URL on your domain, the script runs as if it were part of your site. An uploaded HTML file behaves the same way. Inside an
<img>tag the script doesn't run, but a direct link still works. The wider picture is in XSS in React. - A huge file. With no size limit, one script can fill your storage or run up your bill. OWASP also lists ZIP bombs and XML bombs, small files that expand to a huge size when unpacked or parsed.
- An executable. A
.jspor.phpfile dropped into a web tree runs as the web user if the server is set up to run it. Next.js won't execute it, but other things on a self-hosted box might, and an admin might open it later. - A public bucket. If uploads sit in a bucket anyone can list or read, every file is public, including other users' documents. See public S3 bucket mistakes.
Before and after: a safer upload route in Next.js
Here's a typical insecure file upload in Next.js. It has no login check, no size limit, a type check the client controls, and a filename taken straight from the request. The file lands in public/, so anyone can request it by URL.
import { writeFile } from "node:fs/promises"
import path from "node:path"
export async function POST(req: Request) {
const form = await req.formData()
const file = form.get("file") as File
if (!file.type.startsWith("image/")) { // the client sets this
return new Response("Images only", { status: 400 })
}
const dest = path.join("public/uploads", file.name) // name can hold ../
await writeFile(dest, Buffer.from(await file.arrayBuffer()))
return Response.json({ url: `/uploads/${file.name}` })
}import { randomUUID } from "node:crypto"
import { mkdir, writeFile } from "node:fs/promises"
import path from "node:path"
import { getCurrentUser } from "@/lib/auth" // your own auth helper
const MAX_BYTES = 2 * 1024 * 1024
function detect(b: Buffer): "png" | "jpg" | null {
const hex = b.subarray(0, 4).toString("hex")
if (hex === "89504e47") return "png"
return hex.startsWith("ffd8ff") ? "jpg" : null
}
export async function POST(req: Request) {
const user = await getCurrentUser()
if (!user) return new Response("Unauthorized", { status: 401 })
const file = (await req.formData()).get("file")
if (!file || typeof file === "string" || file.size > MAX_BYTES) return new Response("Bad file", { status: 400 })
const buf = Buffer.from(await file.arrayBuffer())
const ext = detect(buf)
if (!ext) return new Response("PNG or JPEG only", { status: 400 })
const key = `${user.id}/${randomUUID()}.${ext}`
const dest = path.join(process.cwd(), "uploads", key) // outside public/
await mkdir(path.dirname(dest), { recursive: true })
await writeFile(dest, buf)
return Response.json({ key })
}The fixed route checks the session first, caps the size, reads the first bytes, and builds the key itself. It writes outside public/ and returns only a key.
How to fix insecure file uploads, step by step
Do these in order. Each step closes one of the gaps above.
Require login first
OWASP's advice is to let only authorized users upload. Check the session at the top of the route handler or server action, before you read the body.
Allow few types and cap the size
Pick the one or two types your feature needs and reject the rest. Enforce the size limit on the server, because a limit in the form only stops honest users.
Check the first bytes (magic bytes)
PNG files start with
89 50 4E 47and JPEGs withFF D8 FF. This catches a text file renamed to.png. OWASP warns that signature checks are easy to bypass on their own, so for images also decode and re-encode them with a maintained, up-to-date library such as sharp, with resource limits such as size and pixel caps. The example route above doesn't do this yet. OWASP notes that re-encoding doesn't guarantee all malicious content is removed, and the image library itself handles untrusted input.Name the file yourself
Generate a UUID and add the extension from the detected type. Never put
file.nameinto a path. If you want the original name for display, store it in your database and escape it when you render it.Give each user a prefix
Store files at
userId/uuid.extand check ownership on every read. With Supabase, storage policies can enforce the prefix in the database.Serve uploads safely
Keep the bucket private and hand out short-lived signed URLs, or serve files from a separate domain. Send
X-Content-Type-Options: nosniff, andContent-Disposition: attachmentfor anything that isn't a re-encoded image. Most apps don't need SVG uploads, so reject them. Header details are in security headers for Next.js.
No single check is enough. OWASP recommends layering them.
| Stops | Does not stop | |
|---|---|---|
| Extension allowlist | Plain .php or .exe uploads | Double extensions if you parse the name badly |
| Content-Type check | Honest mistakes | Anyone using curl or a proxy |
| Magic bytes | A text file renamed to .png | A valid header followed by hidden code |
| Random filename | Traversal, overwrites, guessing | Bad file content |
| Size limit | Full disks and big bills | Small malicious files |
| Private bucket, separate domain, nosniff | Scripts running on your origin | The malicious content itself |
What each upload check stops and what it misses.
Presigned URLs for direct-to-bucket uploads
With presigned URLs, your server never handles the file. A route checks the session, picks the object key and returns a URL. The browser then sends the file straight to the bucket.
Treat that URL like a password for one action (security docs call it a bearer token). Cloudflare's R2 docs say anyone who holds it can perform the signed operation until it expires. So constrain what it allows:
- Choose the key on the server, under the user's prefix, with a random name.
- Use a short expiry. R2 accepts anything from 1 second to 7 days, and a few minutes is plenty for an upload.
- Sign the Content-Type. R2 includes it in the signature, so a client that sends another type gets a 403 SignatureDoesNotMatch.
This is the PUT version with R2 settings. On AWS S3, drop endpoint and use your bucket's region.
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
import { randomUUID } from "node:crypto"
const s3 = new S3Client({
region: "auto",
endpoint: process.env.S3_ENDPOINT!,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
},
})
export async function presignAvatar(userId: string) {
const key = `${userId}/${randomUUID()}.png`
const url = await getSignedUrl(
s3,
new PutObjectCommand({ Bucket: "my-bucket", Key: key, ContentType: "image/png" }),
{ expiresIn: 300 },
)
return { url, key }
}A presigned PUT URL cannot limit file size. On S3, the content-length-range condition belongs to presigned POST policies:
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: "YOUR_BUCKET_REGION" })
export async function presignPost(userId: string) {
return createPresignedPost(s3, {
Bucket: "my-bucket",
Key: `${userId}/${randomUUID()}.png`,
Conditions: [
["content-length-range", 1, 2 * 1024 * 1024],
["eq", "$Content-Type", "image/png"],
],
Fields: { "Content-Type": "image/png" },
Expires: 300,
})
}R2's docs say POST uploads aren't currently supported. On R2, check the object's size after the upload (a HEAD request returns it) and delete anything too big, or check the current docs for newer options.
Test it yourself
Run these only against your own app, local or staging. Add your session cookie with -H "Cookie: ..." to test as a logged-in user, then repeat once without it. You should get a 401.
# 1. Script inside an SVG, labeled as a PNG echo '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(document.domain)</script></svg>' > evil.svg curl -i -F "file=@evil.svg;type=image/png" http://localhost:3000/api/upload # 2. Path traversal in the filename (use any real PNG) curl -i -F "file=@photo.png;filename=../../pwned.png" http://localhost:3000/api/upload # 3. A 20 MB file head -c 20000000 /dev/zero > big.png curl -i -F "file=@big.png;type=image/png" http://localhost:3000/api/upload
How to read the results:
- Test 1: a 200 and a URL is bad. A 400 is good.
- Test 2: a response that contains
pwned.png, or a file outside your upload folder, is bad. A random key and nothing outside the folder is good. - Test 3: acceptance is bad. A quick 400 or 413 is good.
- If you allow SVG or HTML, open the file URL in a browser. An alert box showing your domain means stored XSS.
- Run
curl -Ion a file URL and look forX-Content-Type-Options: nosniffand, for non-images,Content-Disposition. - Open the bucket's root URL in a private window. A file listing means the bucket is open. Also request a known object URL without a signature. If it downloads, objects are publicly readable even when listing is blocked.
To test a presigned URL, send the wrong type:
echo '<h1>test</h1>' > evil.htm curl -i -X PUT -H "Content-Type: text/html" --data-binary @evil.htm "PRESIGNED_URL"
A 403 SignatureDoesNotMatch is good. A 200 means the type isn't signed.
Fix prompt you can paste
Paste this into Cursor or Claude Code at the root of your repo.
Find every file upload path in this repo: route handlers or server actions that call formData(), multer, busboy or formidable, supabase.storage.from(...).upload, and any code that calls getSignedUrl or createPresignedPost. For each one, list the file path and line number, then check: 1. Is the user authenticated before the body is read? 2. Is there a server-side size limit and an allowlist of types? 3. Are the first bytes checked (PNG 89504e47, JPEG ffd8ff), not just file.type or the extension? 4. Is the stored name generated with randomUUID and never built from file.name? Look for path.join, path.resolve or template strings that include file.name or any client-supplied path, and for writeFile calls into public/. 5. Are files stored under a per-user prefix, outside public/, in a private bucket? 6. Are SVG and HTML uploads rejected, or served with Content-Disposition: attachment and X-Content-Type-Options: nosniff? 7. For presigned URLs: is the key chosen on the server, is expiresIn short, is ContentType signed, and on S3 is a content-length-range condition set in createPresignedPost? First report findings as a list of file:line, what is missing and which attack it allows. Then fix what is missing with minimal changes, keep the current storage provider, and for each change name the file and line you edited. Explain each change in one or two sentences.
If you want a second pair of eyes on the whole app, run it through Leakcade.
Further reading
FAQ
Is checking the file extension enough to secure an upload?
No. Names like photo.jpg.php, odd casing and null bytes get past naive checks. Use an allowlist, validate the full name after decoding it, and check the file's first bytes too. Even better, ignore the user's name and generate your own.
Can I trust file.type or the Content-Type header?
No. The client sets it, and curl or a proxy can change it, so a script-bearing SVG can arrive labeled image/png. Use it only to reject obvious mistakes early, and confirm the real type from the file's first bytes on the server.
How do I limit upload size with a presigned URL?
On AWS S3, use a presigned POST with a content-length-range condition. A presigned PUT URL doesn't carry that condition. Cloudflare's R2 docs say POST presigned uploads aren't currently supported, so check the object's size after upload with a HEAD request and delete anything over your limit.
Is it safe to let users upload SVG files?
Not by default. SVG can contain script that runs when someone opens the file directly on your domain. Reject SVG if you can. If you must allow it, serve it from a separate domain as a download with nosniff.
Where should I store uploaded files in a Next.js app?
Put them in a private object storage bucket, under a per-user prefix, with random names. Keep them out of public/, because anything there can be requested by URL. Serve them through signed URLs or a route that checks ownership.
Want a second pair of eyes?
We review your whole repo and write a fix prompt for every finding. $19, report within an hour.
Check my repoLeakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms