Skip to content
All guides

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 ../../something or a repeated name can overwrite another file, and avatar.png from one user replaces avatar.png from 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.

  1. Find the upload feature and look at its server code, or the Supabase bucket it uses. Is there any check beyond the picker’s accept attribute?
  2. Try uploading something that should be refused: a small .html file containing <b>test</b>, a .txt file renamed to photo.png, and a file much larger than any real user would need. If they all succeed, there are no checks.
  3. Open the URL of the uploaded .html file 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.
  4. 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.
  5. 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: nosniff so browsers do not guess a different type.
  • For anything that is not an image shown on the page, send Content-Disposition: attachment so the browser downloads it instead of displaying it.
  • Where possible, serve user files from a different domain to your app, such as example-usercontent.com for an app on example.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.html can be renamed evil.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.

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].