File upload accepts anything: how to secure uploads in a Next.js or Supabase app
By Obscura Security · Updated · Next.js, Supabase, Firebase, Lovable, Bolt, Cursor
Short answer
If your upload feature accepts any file type and size, users can upload HTML or SVG files that run scripts from your domain, files big enough to run up your storage bill, and names that overwrite other files. Allow-list the types and check the real file contents on the server, set size limits, generate your own file names, and serve user files so browsers download them instead of running them.
An upload form is a way for strangers to put files on a server you pay for, under your domain name. Without limits, they can put anything there.
Why this happens
Upload code generated for a demo does one thing: take the file and save it. Typical shortcuts:
- There is an
accept="image/*"attribute on the input, and no check on the server. That attribute only affects the file picker. A request sent by hand ignores it. - The code trusts
file.type, which is whatever the sender claims. - The file is saved under its original name, so
../../somethingor a repeated name can overwrite another file, andavatar.pngfrom one user replacesavatar.pngfrom another. - There is no size limit, so one person can upload a 5 GB file, or a thousand small ones.
- Files are served back from your own domain exactly as uploaded.
That last point is the serious one. If someone uploads an .html file or an .svg containing a script, and your app serves it from your own origin, the browser runs the script as part of your site. The script can reach the logged-in user’s session. It is stored cross-site scripting, with the file as the delivery method. The size problem is more mundane but still hurts: storage and bandwidth are billed to you.
How to check your own app
Do these with your own test account against your own app.
- Find the upload feature and look at its server code, or the Supabase bucket it uses. Is there any check beyond the picker’s
acceptattribute? - Try uploading something that should be refused: a small
.htmlfile containing<b>test</b>, a.txtfile renamed tophoto.png, and a file much larger than any real user would need. If they all succeed, there are no checks. - Open the URL of the uploaded
.htmlfile in your browser. If the page renders as HTML instead of downloading, the file is being served as a web page from your storage domain. - In Supabase, open Storage, choose the bucket, and look at its settings for the file size limit and allowed MIME types. If both are empty, the bucket accepts anything.
- Look at how the stored name is built. If it includes the original file name from the user, it needs changing.
Delete the test files afterwards.
The fix
1. Allow-list types and check the real contents
Decide which types you accept, and reject everything else. Check the bytes, not the extension or reported type. Here is a Next.js route handler that does that for images:
import { fileTypeFromBuffer } from 'file-type'
import { randomUUID } from 'node:crypto'
const ALLOWED = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/webp': 'webp',
} as const
const MAX_BYTES = 5 * 1024 * 1024 // 5 MB
export async function POST(req: Request) {
const user = await getCurrentUser()
if (!user) return Response.json({ error: 'Unauthorised' }, { status: 401 })
const form = await req.formData()
const file = form.get('file')
if (!(file instanceof File)) {
return Response.json({ error: 'No file' }, { status: 400 })
}
if (file.size > MAX_BYTES) {
return Response.json({ error: 'File too large' }, { status: 413 })
}
const bytes = Buffer.from(await file.arrayBuffer())
const detected = await fileTypeFromBuffer(bytes)
const ext = detected ? ALLOWED[detected.mime as keyof typeof ALLOWED] : undefined
if (!detected || !ext) {
return Response.json({ error: 'Unsupported file type' }, { status: 415 })
}
// Our own name: the user's file name is never used.
const key = `${user.id}/${randomUUID()}.${ext}`
await saveFile(key, bytes, detected.mime) // your storage upload here
return Response.json({ key })
}
For extra safety on images, re-encode them with an image library such as sharp before saving. Re-encoding usually drops anything hidden in the file besides the picture.
2. Set limits where the files land
Check the size in your code and also in the storage layer, since uploads that go straight from the browser to storage never pass through your server. In Supabase, edit the bucket’s settings and set a file size limit and allowed MIME types. With the JavaScript client, the same options exist when creating a bucket:
await supabase.storage.createBucket('avatars', {
public: false,
fileSizeLimit: 2 * 1024 * 1024,
allowedMimeTypes: ['image/png', 'image/jpeg', 'image/webp'],
})
These filters are based on what the upload declares, so treat them as a first line and keep the server-side content check when the file type matters. Add a per-user quota or a rate limit on the upload endpoint as well, since many small files add up.
3. Generate the file name and path
Use a random name and a folder you control, as in the code above. Never put the user’s file name into a path. If you want to show the original name in your interface, store it in a database column as text, and escape it when you display it.
4. Serve user files safely
- Send
X-Content-Type-Options: nosniffso browsers do not guess a different type. - For anything that is not an image shown on the page, send
Content-Disposition: attachmentso the browser downloads it instead of displaying it. - Where possible, serve user files from a different domain to your app, such as
example-usercontent.comfor an app onexample.com. A script in a file on another origin cannot read your app’s cookies or storage. A subdomain can receive cookies set for the parent domain, so a separate registered domain is the safer choice. - Keep sensitive uploads in a private bucket and hand out short-lived signed URLs. See Supabase storage bucket public.
5. Leave SVG out unless you sanitise it
An SVG can carry a script that runs when someone opens the file directly. Accept PNG, JPEG and WebP for avatars and logos. If you must allow SVG, run it through a sanitiser server-side, and serve it as an attachment or from the separate domain.
Mistakes to avoid
- Relying on
accept="image/*". It only filters the file picker. - Checking the extension.
evil.htmlcan be renamedevil.png, and a file can be named anything. - Checking only in the browser. Size and type checks written in React do not stop a direct request.
- Using the original file name in the storage path. This allows overwrites and path tricks.
- Public bucket for everything. Public means anyone with the URL can fetch the file, with no login.
Common questions
- Can I trust the file type the browser reports?
- No. The Content-Type and the file extension are both set by whoever sends the request. Check the first bytes of the file on the server (its magic bytes) with a library such as file-type, and compare the result to your allow-list.
- Is it safe to allow SVG avatars?
- SVG is an image format that can contain scripts. It is safe in an img tag, but a script runs if someone opens the file's URL directly. Unless you sanitise SVGs, allow PNG, JPEG and WebP instead.
- Do Supabase buckets have upload limits?
- Yes. In a bucket's settings you can set a file size limit and a list of allowed MIME types, and there is also a project-wide upload size limit. Use them as a first filter, and still check the file contents on your server if the type matters.
- How big should the size limit be?
- As small as your feature needs. A profile picture rarely needs more than a couple of megabytes. A tight limit is the cheapest protection against surprise storage and bandwidth costs.
Read next
Written by Obscura Security, an Australian practice that checks web apps for problems like this one. If you want a second pair of eyes on yours, email [email protected].