A security review comes back on your app’s receipt upload form with one line: follow the OWASP File Upload Cheat Sheet. The reviewer wants to see each control in place before the form ships.
The OWASP File Upload Cheat Sheet opens with eleven controls. Allow only the file types the feature needs, and don’t trust the Content-Type header. Rename every file to a name your application generates, and limit the name’s length and characters. Set a size limit, and let only authorized users upload. Store files on a separate host, or outside the webroot. Scan them with antivirus or a sandbox, run documents through content disarm and reconstruction (CDR), keep your file libraries up to date, and protect the upload against cross-site request forgery (CSRF). The cheat sheet asks for them together, because no single control is enough on its own.
In addition, Filestack, our API for uploading, processing and delivering files, runs several of these before a file reaches your servers. The file picker checks type and size in the browser, and with application security on, a signed policy decides who may upload. Every file is stored under a random handle and served from our CDN, and Virus Detection scans uploads in a workflow. Each one is part of our secure file upload layer.
For this post we wrote a picker page with an allow list and a short Node script that checks a stored file’s signature.
Key takeaways
- The OWASP cheat sheet asks for an allow list of types, generated names, size limits, authorized uploaders, separate storage, scanning and CDR.
- A browser check guides the user, and the same rules run again after upload because a request can skip the browser.
- A file signature check reads the first bytes, and the cheat sheet pairs it with other checks rather than using it alone.
- Serving uploads from a separate host under generated names keeps an uploaded script off your app’s domain.
- Who may upload and who may open each file stay rules in your app, and signed policies enforce them.
What does the OWASP File Upload Cheat Sheet cover?
The OWASP File Upload Cheat Sheet covers two threats and nine groups of protection. The first threat is malicious files, such as a file that exploits an image parser, a ZIP bomb or a script that runs in another user’s browser. The second is public file retrieval, where stored files leak or anyone can request them. We trace each of those upload and sharing security risks from intake to delivery in a separate post. The nine protection groups, in the cheat sheet’s order:
| Group | What it asks for |
|---|---|
| Extension validation | An allow list, checked after decoding the name, with double extensions, null bytes and case tricks blocked |
| Content-Type validation | The header treated as a hint, since the uploader sets it |
| File signature validation | The first bytes checked, together with other type checks and never alone |
| Filename safety | A generated name such as a UUID, a length limit and a short list of allowed characters |
| File content validation | Images rewritten, documents validated, ZIP files avoided, files scanned |
| File storage location | A different host first, a folder outside the webroot second |
| User permissions | Uploads only from identified, authorized users |
| Filesystem permissions | Least privilege on the stored files |
| Upload and download limits | A size limit, sizes checked after decompression, and request limits on downloads |
Which controls run in the browser and which on the server?
Type and size checks can run in the browser, and every control that stops a file runs on the server, because a request can skip the browser entirely. The browser check is for the user. It rejects the wrong file before it uploads and says why.
This page opens our JavaScript file picker with an allow list of three types and a 10 MB limit:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<script src="https://static.filestackapi.com/filestack-js/4.x.x/filestack.min.js"></script>
</head>
<body>
<script>
const client = filestack.init('YOUR_API_KEY');
client.picker({
fromSources: ['local_file_system'],
// Allow list: the only types this form takes, and the largest size, 10 MB
accept: ['image/png', 'image/jpeg', 'application/pdf'],
maxSize: 10 * 1024 * 1024,
onUploadDone: (result) => {
for (const file of result.filesUploaded) console.log('stored', file.handle, file.mimetype);
},
}).open();
</script>
</body>
</html>
Replace YOUR_API_KEY with your API key and open the page in a browser. If your application only allows uploads from listed domains, open it from one of them. A text file gets this message:
A 12 MB PDF gets The file scan-large.pdf exceeds the size limit of 10.00 MB. A PNG receipt and a file named invoice.pdf both go through. The second one holds an HTML page, and the browser reads its type from the name. After Upload, the console shows:
stored 4M37jeQuQ0W6kdg7GzzS image/png
stored O8iNCMAxRreZzOU2ZxiT application/pdf
On the server, the size limit and the permission to upload live in a signed upload policy. With application security turned on in the Developer Portal, every upload needs one. Your backend signs it with your app secret, with call: ['pick', 'store'], a short expiry and a maxSize, and the page passes it as filestack.init('YOUR_API_KEY', { security: { policy, signature } }). A request that skips the picker then has no policy to upload with. Each field is described in our docs on security policies and signatures. The type check after upload runs in your app, on the stored file.
How do you check a file’s signature?
Check a file’s signature by reading its first bytes and comparing them with the bytes each allowed format starts with. A PNG starts with 89 50 4E 47 0D 0A 1A 0A, a JPEG with FF D8 FF and a PDF with %PDF-. Browsers identify the common formats from the same byte patterns, set out in the MIME sniffing standard. check-signature.mjs reads the first 16 bytes of a stored file from our CDN and checks them against those three. It’s plain Node 18 or later, with no packages:
// Reads the first bytes of a stored file and checks them against the signatures you allow
const SIGNATURES = {
'image/png': [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
'image/jpeg': [0xff, 0xd8, 0xff],
'application/pdf': [0x25, 0x50, 0x44, 0x46, 0x2d], // %PDF-
};
const handle = process.argv[2];
// A range request downloads only the first 16 bytes, whatever the file's size
const res = await fetch(`https://cdn.filestackcontent.com/${handle}`, { headers: { Range: 'bytes=0-15' } });
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const head = new Uint8Array(await res.arrayBuffer()).subarray(0, 16);
const type = Object.keys(SIGNATURES).find((t) => SIGNATURES[t].every((byte, i) => head[i] === byte));
if (type) {
console.log(`${handle} starts like ${type}`);
} else {
console.log(`${handle} matches no allowed signature, first bytes ${Buffer.from(head.subarray(0, 8)).toString('hex')}`);
process.exitCode = 1;
}
Run it with the handle the picker logged:
node check-signature.mjs HANDLE
For the two files the picker stored, it prints:
4M37jeQuQ0W6kdg7GzzS starts like image/png
O8iNCMAxRreZzOU2ZxiT matches no allowed signature, first bytes 3c21646f63747970
3c21646f63747970 is <!doctyp, the start of an HTML page, so the script exits with code 1 and your app doesn’t save that handle. A handle that doesn’t exist throws 404 file not found. With application security turned on, add a signed read policy to the URL, the same way the upload policy is signed. The cheat sheet says a signature check shouldn’t be used on its own, because a file can start with valid bytes and carry something else after them. Virus Detection reads the whole file, so the two run together.
Where should uploaded files be stored and served from?
OWASP’s first choice is a different host from your application, and its second is a folder outside the webroot. A file that’s never served from your app’s domain can’t run as a script on that domain, and a file outside the webroot can’t be requested as a page. Files uploaded through the picker are stored by us, or in your own cloud storage bucket, and served from cdn.filestackcontent.com. Each file gets a random handle such as 4M37jeQuQ0W6kdg7GzzS, and the URL uses the handle, so the uploader’s filename never becomes a path. For files the public can open, the cheat sheet suggests an ID that maps to the file inside the application, and a handle is that ID. The original name is kept as metadata. Limit its length and characters before you show it to other users. Random names also close the overwrite threat the cheat sheet lists, where an upload replaces an existing file with the same name. Random paths and a separate serving domain are both part of our guide to validation, scanning and storage.
What are the options for meeting the cheat sheet?
There are three common ways to put the cheat sheet’s controls in place:
| Option | What you build | The catch |
|---|---|---|
| Your own upload endpoint | Validation, renaming, storage outside the webroot, and a scanner such as ClamAV | Every control is your code to write, test and keep updated |
| Cloud storage with presigned uploads, such as Amazon S3 | Uploads straight to a bucket on a separate host, with a size range in the presigned POST policy | Type checks, renaming and scanning are still your code |
| A managed upload service such as Filestack | The picker, signed policies, separate storage and delivery, and Virus Detection | The rules about your users, CSRF and CDR stay in your app |
We built the picker and the policies so the controls that sit on the file run before the file reaches your servers. That leaves your code with the rules only your app can decide.
Which OWASP controls does Filestack run, and which stay yours?
Filestack runs the controls that sit on the file itself. Those are the picker’s type and size check, the size limit and upload permission in a signed policy, generated names, separate storage and delivery, and the virus scan. The rules about your users stay in your app.
| Cheat sheet control | Filestack runs | Your app owns |
|---|---|---|
| Allow list of types | accept in the picker |
The signature check after upload |
| Size limit | maxSize in the picker and in the upload policy |
The limit for each form |
| Generated names | A random handle for every file | Cleaning the original name before you show it |
| Authorized uploaders | A signed upload policy with an expiry | Signing it only for users who are logged in |
| Separate storage | Our storage or your bucket, served from our CDN | Your bucket’s access settings, when you store to your own |
| Who may open a file | An expiring read policy for one handle, with application security on |
Which user may open which file |
| Antivirus | Virus Detection in a workflow | What your app does with a failed scan |
The virus detection API runs in a workflow on the Start, Grow and Scale plans. Attach the workflow to the picker with storeTo: { workflows: ['YOUR_WORKFLOW_ID'] }, and a store task after the scan keeps only clean files. CDR isn’t a Filestack task. A workflow can deliver each stored document to your webhook, and your endpoint passes it to a CDR service before the file is published. CSRF tokens on your own forms and endpoints stay with your web framework.
Questions people ask
Is the OWASP File Upload Cheat Sheet a standard?
The OWASP File Upload Cheat Sheet is guidance, not a standard. It’s part of the OWASP Cheat Sheet Series, a set of practical guides, and a security review can cite it as the checklist for an upload feature.
Does OWASP recommend a block list of file extensions?
OWASP recommends an allow list of file extensions instead of a block list. The cheat sheet calls blocking specific extensions a weak protection on its own, because a server can run extensions such as .phtml or .php5 that a block list misses.
Is the Content-Type header enough to check a file’s type?
The Content-Type header isn’t enough to check a file’s type, because the uploader sets it. In the cheat sheet’s words, it’s trivial to spoof. Use it as a quick check against a wrong file, then check the signature on the server.
What is CWE-434?
CWE-434 is Unrestricted Upload of File with Dangerous Type, the weakness where an app accepts a file that it then runs or serves as active content. The cheat sheet’s controls are what closes CWE-434 unrestricted upload in an upload feature.
Try it
The picker page and check-signature.mjs both run on any plan, including the free one. Create a free account for an API key, upload a file through the picker, and pass its handle to the script. Next, sign the upload policy on your backend and add the Virus Detection workflow to the picker’s storeTo.
Carl is a Product Marketing Manager at Filestack with four years of hands-on experience in React, JavaScript, Django, and Python. He bridges the gap between product and developer, translating how Filestack’s APIs and SDKs actually work into content that’s useful for the engineers building with them. His writing covers file handling workflows, upload integrations, and real-world implementation patterns, written from the perspective of someone who has built with these tools firsthand.
Read More →

