The first time a customer uploads a passport scan or signed contract, that file’s URL either opens for a stranger or it does not. File upload testing is how you find out before they do: 8 tests covering disallowed types, oversized files, downloads without a signed link, and bucket listings, run against your own app. I’d allow about an hour.
File upload testing: what secure upload functionality is, and the 8 tests
File upload testing for a web app is 8 requests sent from outside the UI: a wrong type, a disguised type, an oversized file, an oversized body, an upload with no session, another user’s file path, a private file with no signed link, and the bucket listing. Each must be refused.
Uploads are one control in a longer list, and where they sit next to authentication, headers and the rest is laid out under web app security. This page stays on the files.
Secure upload functionality comes down to four promises, in my framing: only the types you allow, only up to the size you allow, stored where the public cannot browse, and readable only by the people who should read it. That is also the working answer to how to secure file uploads: make each promise hold on the server or in the bucket, then prove it with a request the browser never sent.
| # | Test | What you send | Expected result |
|---|---|---|---|
| 1 | Wrong extension | A file whose extension is not on your allow list | Refused, and nothing is stored |
| 2 | Right extension, wrong content | A file named as an allowed type whose bytes are something else | Refused, or held in quarantine and never served |
| 3 | Oversized file | A file a little over the app’s own size limit | Refused, and nothing is stored |
| 4 | Oversized request body | A body over the cap of the proxy or platform in front of the upload route | Refused, and nothing is stored |
| 5 | Upload without a session | The upload request with no session cookie and no Authorization header | Refused, and nothing is stored |
| 6 | Another user’s file | Signed in as user B, a request for user A’s object path | Refused, and no file bytes come back |
| 7 | Private file, no signature | The private file’s path with no signed link, then with an expired one | Refused, and no file bytes come back |
| 8 | Bucket listing | A list request as an anonymous caller and as user B | Refused, and no name of another user’s file comes back |
The last column never names a status code. Providers answer a refusal differently, so record what yours returns and treat that as the expected value from then on.
In QA work, upload testing means something else: checking that the upload widget works at all, for example with a Playwright test that picks a file and presses the button. That is a different job, and this page covers the security one.
Add two columns, the date you ran each row and what came back, and the table becomes a file upload security checklist you can keep. Run before each release, it doubles as a file upload pentest checklist for your own app.
What goes wrong without it: file upload vulnerabilities
The four failures below are what a file upload vuln comes down to on an app that stores files in a bucket, listed in the order such an app meets them (my reading, not a measured order).
| Failure | What the stranger does | What the owner sees | The test that catches it |
|---|---|---|---|
| Public bucket | Anyone can open uploaded files by URL, with no login | Nothing unusual; the file opens as it always did | Tests 6 and 7 |
| Listable bucket | One list request returns every object name | Nothing in the app; the names only show in the response | Test 8 |
| No server-side limit | Sends the request directly, skipping the browser’s type and size check | Files of any type or size in the bucket | Tests 1 to 4 |
| A file that runs in a browser | Uploads an SVG or HTML file that your own domain then serves | Script running in other users’ sessions | Tests 1 and 2 |
The first two rows compound. When paths follow a pattern, such as a user id and a date, a public bucket makes every file findable, not only the ones whose links leaked (my reading). The fourth row is its own subject: OWASP’s cheat sheet lists client-side active content that “could endanger other users if the files are publicly retrievable”, and the fix for that side lives in how to mitigate cross-site scripting.
Take a founder whose app lets customers upload signed contracts. The files go into a Supabase Storage bucket marked public, so the app can show each one with a plain link. A customer forwards one of those links, and whoever receives it opens the contract with no login, because in a public bucket, in the words of Supabase’s bucket fundamentals, “anyone who possesses the asset URL can readily access the file”. Weak upload and storage controls can expose documents or accept unsafe files. The lesson I take from it: if a file should not be read by a stranger, it does not belong in a public bucket, however private the link looks.
OWASP’s page on unrestricted file upload opens with “Uploaded files represent a significant risk to applications” and says the consequences depend on “what the application does with the uploaded file and especially where it is stored.” The OWASP Top 10 has no entry named for unrestricted file upload: the 2025 list, as fetched on September 27, 2026, runs from A01:2025 Broken Access Control to A10:2025 Mishandling of Exceptional Conditions. Inside the Top 10, OWASP files this file upload vulnerability under A06:2025 Insecure Design, whose page names CWE-434, Unrestricted Upload of File with Dangerous Type, among its notable weaknesses.
Supabase adds a guard of its own: by default, Supabase Storage does not allow uploads to buckets without RLS policies. In my reading, that moves the risk into the policy itself. A policy written in a hurry to make uploads work can end up allowing everyone, so read what each storage policy allows before you trust it. Platform storage defaults, and the other settings a managed platform ships with, are covered in insecure defaults on managed platforms.
Shell upload and file upload bypass: the attack side, and what applies to a bucket
A shell upload places a script where the web server will execute it, so it needs a server that runs files from its upload folder. Object storage executes nothing. File upload bypass is the set of tricks that fool a weak check, among them doubled, alternative or case-changed extensions, a spoofed content type and a faked file signature.
OWASP’s community page puts the server-side case plainly: “The web server can be compromised by uploading and executing a web-shell”. Shell uploading works only when the server runs what lands in its upload folder. A bucket in Supabase Storage or Amazon S3 stores and serves bytes, so the classic upload shell does not apply there. It applies again the moment your app writes uploads to the application server’s own disk, to resize them or to hand them to another tool (my reading of the mechanism).
Bypass works against the check, not the storage. OWASP’s File Upload Cheat Sheet names the extension tricks by class, among them double extensions, null bytes, case manipulation, and alternative extensions that a server may map to the same interpreter. It calls the Content-Type header “trivial to spoof”, and says a file signature check “should not be used on its own”. The defense for each is in the next section.
How to do it on Supabase Storage, S3 and your own server
An upload can pass three layers before it is stored: the app’s own route, the proxy or platform in front of it, and the bucket. The last column below is my reading. A client-direct upload, where the browser sends the file straight to the bucket with a signed upload URL, skips the app and the proxy, so only the bucket’s settings or the signed upload’s own conditions still apply.
| Layer | The limit it enforces | Where it is set | What bypasses it |
|---|---|---|---|
| App (your upload route) | Session, type, size, filename | Your route’s code, before the storage client’s upload() call | A client-direct upload, which never reaches the route |
| Proxy (nginx) | Request body size | client_max_body_size in the http, server or location context; default 1m | A client-direct upload, which never passes the proxy |
| Platform (Vercel Functions) | Request body size | Vercel’s own limit: 4.5 MB for a Function’s request body | A client-direct upload, which never reaches the Function |
| Bucket (Supabase Storage) | File size and allowed MIME types | The bucket’s fileSizeLimit and allowedMimeTypes options, under the project’s global limit | Nothing on the upload path; whether the type check reads the file’s bytes is not stated in Supabase’s docs |
| Bucket (Amazon S3, presigned upload) | Size and type, only if the signed request says so | Presigned PUT: a content type fixed at signing if one is set, no size limit stated; presigned POST: policy conditions such as content-length-range | A PUT URL signed with no size condition |
File upload validation: limit upload file size and type on the server
File upload validation belongs on the server: an allow list of extensions, a file-type check that never trusts the request’s Content-Type header, a generated filename, a size limit, an authorized uploader, and storage off the application server. A browser-side check is a convenience, never a control.
OWASP’s File Upload Cheat Sheet opens with a list of 13 principles for a secure upload. For file upload vulnerability prevention on managed storage, the six rules below are my selection from that list, restated for this stack.
- 01 Allow only the extensions the feature needs, from an allow list
- 02 Validate the file type without trusting the Content-Type header
- 03 Enforce a size limit on the server or in the bucket, never only in the browser
- 04 Replace the filename with one the application generates
- 05 Store files on a different server or outside the web root, and a bucket counts as a different server
- 06 Let only authorized users upload
Rules 3 and 5 carry my additions: OWASP says to set a file size limit, and “never only in the browser” is mine, as is counting a bucket as the different server.
With client-direct uploads the app never sees the bytes, so the limits must live in the bucket’s own settings or in the signed upload’s conditions. On Supabase Storage, “Upload restrictions like max file size and allowed content types are also defined at the bucket level”. A bucket’s limit “can’t be higher than this global limit”, and “For Free projects, the limit can’t exceed 50 MB”, per Supabase’s upload file limits. On Amazon S3, AWS’s presigned upload page states no size limit for a presigned PUT; if a content type is set when the URL is generated, the upload must send that same one, which is a header the client sends, not the file itself (my reading). A presigned POST takes a policy instead, whose conditions, in boto3’s reference, “may pertain to acl, content-length-range, Cache-Control, Content-Type”, with ["content-length-range", 2, 5] as its example.
To limit upload file size server side on that design, do not lean on the route that signs the upload. That route reads only the size the browser declares and never the file, so it is not the cap (my reading). Supabase’s docs do not say whether the bucket’s allowed-types check reads the file’s bytes, and they say that by default Storage “will assume the content type of an asset from the file extension”. So on a client-direct design, the content check runs on the stored object in the quarantine step described under malware scanning below, before the file is served (my reading).
When the file does pass through your server, the route is short. This one checks the session, the size, the extension and the file’s first bytes against the PNG signature from the W3C PNG specification, then writes to a private bucket with Supabase’s upload() call.
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; // W3C PNG spec, section 5.2
const MAX_BYTES = Number(process.env.MAX_UPLOAD_BYTES) || 0; // your app's own limit in bytes; unset refuses every upload
export async function POST(req: Request) {
const user = await getSessionUser(req); // your auth helper
if (!user) return new Response('Sign in first', { status: 401 });
const file = (await req.formData()).get('file');
if (!(file instanceof File) || file.size > MAX_BYTES || !file.name.toLowerCase().endsWith('.png')) return new Response('Rejected', { status: 400 });
const head = new Uint8Array(await file.slice(0, 8).arrayBuffer());
// Each allowed type needs its own signature check, taken from its own specification.
if (!PNG_SIGNATURE.every((byte, i) => head[i] === byte)) return new Response('Rejected', { status: 400 });
const { error } = await supabase.storage // your server-side Supabase client
.from('private-uploads').upload(`${user.id}/${crypto.randomUUID()}.png`, file, { contentType: 'image/png' });
return error ? new Response('Upload failed', { status: 500 }) : new Response(null, { status: 201 });
}
413 and nginx client max body size: the limit in front of the app
A 413 from nginx means the request body exceeded client_max_body_size, whose default is 1m, one megabyte. My working rule is to set it just above the app’s own upload limit, never to 0, which turns the check off. The app never sees a rejected request.
In nginx’s client_max_body_size reference, the directive “Sets the maximum allowed size of the client request body” and can go in the http, server and location contexts. Over the limit, “the 413 (Request Entity Too Large) error is returned to the client”, and nginx adds that “browsers cannot correctly display this error”. That note is the reason for my working rule: with the proxy’s cap a little above the app’s, an oversized file reaches the app and the user gets the app’s own message, not a raw error page. The nginx client-max-body-size directive also takes 0, and “Setting size to 0 disables checking of client request body size”, which leaves no proxy limit at all.
A serverless platform can have a request body cap of its own. Vercel’s function limits say “The maximum payload size for the request body or the response body of a Vercel Function is 4.5 MB”, and a bigger payload gets “error 413: FUNCTION_PAYLOAD_TOO_LARGE” (page last updated August 24, 2026). That cap is the practical reason to upload large files straight to the bucket with a signed upload URL (my reading).
Bucket permissions, and how to use signed URLs for private files
A signed URL is a time-limited link to one private file, created by the server after it checks the user may read that file. The bucket stays private, the link expires in minutes (my working rule), and anyone holding it can use it until then, so expiry is the control.
Keep every bucket private unless its files are meant for everyone. On Supabase, “Buckets are private by default”, and in a private bucket “all operations are subject to access control via RLS policies. This also applies when downloading assets.” The upload rule from the section above still holds; what matters here is what each policy lets a caller read.
Tie each object’s path to its owner: a folder per user or per tenant id, checked in the policy. Supabase’s access-control guide shows the pattern, a policy that compares the first folder of the path, (storage.foldername(name))[1], with the signed-in user’s id. Listing needs the same care. The same guide warns, about a policy that opens a bucket’s files to anyone, that without the right operation filter “users would be able to list the bucket contents”, so refuse listings for anonymous and cross-user callers. How Supabase’s public flag and its storage policies interact is explained in how a public Supabase bucket skips its policies.
For private files, the signed link carries the access, in five steps:
- 01 Keep the bucket private
- 02 In a server route, check that the signed-in user may read this file
- 03 Create the signed URL with a short expiry, minutes for a download
- 04 Return the URL to that user
- 05 Never store the URL; create a fresh one on each request
On Supabase, step 3 is createSignedUrl(path, expiresIn). Supabase’s guide to serving files gives a reason to keep that expiry short: “Signed URLs remain valid until their expiry time regardless of any Auth key changes. If you need to revoke signed URLs, contact Supabase support.” On Amazon S3, step 3 is a presigned GET, and Amazon S3’s presigned URL guide treats the link as a bearer token: whoever holds it can use it, more than once, until it expires. On both, the lifetime is what limits a leaked link, which is why my working rule for a download link is minutes, not hours. How long each kind of file’s link should live, and how the bucket is managed over time, is a separate list: the object storage security checklist.
Image processing, and whether ImageMagick is safe
ImageMagick is safe enough for user uploads, in my reading, only when it is set up for them: a current version, a security policy that disables the coders you do not need, and caps on memory, time and image dimensions. Its own policy page calls its model everything allowed unless denied.
OWASP’s cheat sheet says that for images, “applying image rewriting techniques destroys any kind of malicious content injected in an image”. In my reading, rewriting moves the risk rather than removing it: the library doing the rewriting now parses hostile input.
ImageMagick’s security policy page says “ImageMagick’s security model is “everything allowed unless denied,” and the last matching policy wins”, and that “ImageMagick is intentionally open by default”. For a public website it suggests “disabling certain coders such as MVG or HTTPS”, and it states the general rule: “The more untrusted input you expect to handle, the more restrictive your policy should be.” The project’s own example policy, which is an example and not a standard, sets resource limits that include these:
- time: 60 seconds
- memory: 256MiB
- disk: 1GiB
- width and height: 4KP each
- area: 16KP
The same page says “It is important to use a current version of ImageMagick to take advantage of the latest security fixes and updates.” libvips is another image-processing library; I give no verdict on it here.
Scan user uploads for malware: what it takes
Scanning user uploads for malware takes 3 parts: a quarantine location, a scan job, and a move to the served path only after a clean result. It matters most when users download each other’s files. ClamAV is an open-source antivirus toolkit that can do the scan.
OWASP’s list includes “Run the file through an antivirus or a sandbox if available to validate that it doesn’t contain malicious data”. Applied to a bucket, that means no one but the uploader can download a file until an engine has passed it. The pipeline I recommend: every upload lands in a quarantine path nobody else can read, a job scans it, and only a clean result moves it to the path the app serves from.
ClamAV’s documentation describes it as “an open source (GPLv2) anti-virus toolkit, designed especially for e-mail scanning on mail gateways”, whose core “is an anti-virus engine available in a form of shared library”. The scan earns its cost when one user’s file reaches another user: shared documents, attachments, anything a team downloads. When a file is only ever read back by the person who uploaded it, the case is weaker (my reading).
How to verify it: the 8 tests, run against your own app
Secure file uploads are verified by 8 refused requests, each saved with its status code and date. Run them from a terminal, never the app’s UI, and again after any change to storage policies or the upload route.
These are the file upload vulnerability test cases for your own app only; never point them at someone else’s. Use two test accounts of your own, user A and user B, and send each request with curl or a similar client so the browser’s checks play no part.
- 01 Test upload of a disallowed file type with curl, not the browser. Expect a refusal and no stored object. Keep the status line, the response body, and a bucket view showing no new object.
- 02 Send an allowed extension on disallowed content. Expect a refusal, or on a client-direct design the file held in quarantine and never served. Keep the response and the object's state.
- 03 Send a file a little over the app's limit. Expect the app's own size error; on a client-direct design, sent through the signed upload, expect the bucket's or the upload policy's refusal and no stored object. Keep the response.
- 04 Where nginx or a platform sits in front of the upload route, send a body over its cap. Expect nginx's 413, or your platform's own refusal. Keep the status line.
- 05 Upload with no session cookie and no Authorization header. Expect a refusal. Keep the response.
- 06 Signed in as user B, with B's own credentials, request user A's object path. Expect a refusal. Keep B's request and the refusal, and A's successful request for the same path, which proves the path is real.
- 07 Test private file access without a signed link, then with an expired one. Expect a refusal both times. Keep both responses.
- 08 List the bucket as an anonymous caller and as user B. Expect a refusal or an empty list for the anonymous caller, and no object of A's for B. Keep both listings.
For test 4 on Vercel Functions, the refusal is the 413 error named in the section on nginx and platform caps. For test 8, user B may still see their own folder, since the policies tie each path to its owner. Save every run’s output in one dated file; a later run that differs from the last one is the first sign that a policy or the route changed.
Test 1 from a terminal looks like this, with your own test account’s token:
curl -s -o response.txt -w "%{http_code}\n" \
-H "Authorization: Bearer $TEST_TOKEN" \
-F "file=@sample.txt" https://your-app.example/api/upload
-F sends the file as a multipart form upload, -o saves the response body as evidence, and -w "%{http_code}" prints the status code once the request finishes.
In the sprint, we verify two deliverables with these tests. For deliverable 3.2 the check is to test disallowed formats, oversized files, unauthorized downloads, and storage listings. For deliverable 4.14 the checks include: fetch a private file without a signed link and confirm refusal; upload an oversized and a disallowed file and confirm rejection.
Files served back to a browser also need nosniff and a content security policy, which belong to the content security policy unsafe-inline fix and the other production headers.
Where the sprint does this
Under deliverable 3.2 of the Production Hardening Sprint, we validate upload types and sizes, enforce bucket permissions, and protect access to stored files. Under deliverable 4.14, we use signed, expiring links for private files, enforce size and type limits, scan user uploads for malware, and set a retention rule for uploads. The results go into the production readiness report, deliverable 13.1, which accounts for all 123 IDs, keeps failures visible until resolved and explains genuine non-applicable items. Formal third-party certifications and independent audit opinions are separate from the sprint deliverables. Every deliverable, with how we verify it, is listed in the published scope.
Common questions about file upload security
What are the OWASP security guidelines for file upload?
OWASP’s File Upload Cheat Sheet gives them as 13 principles. Allow only the extensions the business needs, validate the file type without trusting the Content-Type header, generate the filename, limit the filename’s length and the file’s size, allow only authorized users to upload, store files on a different server or outside the webroot, and run files through an antivirus or a sandbox if one is available. The rest cover input validation before the extension check, a handler that maps public files to internal names, CDR for document types, securely configured and up-to-date libraries, and CSRF protection.
Can anyone access a presigned URL?
Yes, anyone who holds it can, until it expires. AWS says presigned URLs “are bearer tokens that grant access to those who possess them” and that “You can use the presigned URL multiple times, up to the expiration date and time.” My working rule is to keep the expiry short and never store the link.
How long do presigned URLs last?
As long as whoever creates one sets, within the provider’s limits. On Amazon S3, a presigned URL made in the console can last between 1 minute and 12 hours, one made with the AWS CLI or SDKs as long as 7 days, and one made with temporary credentials expires when those credentials do. Supabase’s createSignedUrl takes the expiry as “The number of seconds until the signed URL expires”.
What is the default file size limit for NGINX?
nginx caps the request body rather than files as such, and client_max_body_size defaults to 1m, one megabyte. A bigger body gets a 413 (Request Entity Too Large) error.
How do I fix a 413 error?
Raise the limit that returned it, or route large files around it. For nginx, my working rule is to set client_max_body_size a little above the app’s own upload limit, and never to 0, which disables the check. On Vercel Functions the request body limit is 4.5 MB, so there the fix is to upload large files directly to the bucket with a signed upload URL.
The checks in this guide show you where the app is open. The sprint below closes those gaps, tests the result and writes the evidence down.
Built it with AI. Now it has to hold up for real customers.
The Production Hardening Sprint takes the app you already have and builds the production foundation underneath it. Authentication and access rules, payments that stay consistent, error handling, monitoring, backups, automated tests and a documented handover. Our engineers work inside your existing codebase for ten working days. All 123 deliverables are included, and you get the evidence for each one.
See the Production Hardening Sprint →
$2,500 fixed price · 10 working days · One codebase