Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Web Security (OWASP Top 10:2025)  Module 7 – A06:2025 Insecure Design

Secure file uploads and downloads

Accept only the file types you need by checking their bytes, store them under server-made names, serve them safely, and limit archives and images.

  • Intermediate
  • 15 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free intro, Pro: 3 of 15 minutes free
Free: An upload is input you will serve back. With a pass: the other 8 sections and 3 interview answers, about 12 minutes.

What you will learn

  • Validate uploads by size, an allowlist of formats and their first bytes, never by the client's file name or Content-Type
  • Store files under server-generated names where nothing runs them, and serve them from a separate origin with safe headers
  • Limit archives and images before processing them, and rebuild images from their pixels
  • Design quarantine, scanning and cleanup for uploads, and authorise every download

Before you start

What you’ll learn · Before you start

What you will learn

  • Validate uploads by size, an allowlist of formats and their first bytes, never by the client's file name or Content-Type
  • Store files under server-generated names where nothing runs them, and serve them from a separate origin with safe headers
  • Limit archives and images before processing them, and rebuild images from their pixels
  • Design quarantine, scanning and cleanup for uploads, and authorise every download

Before you start

On this page

A profile-photo form accepted a file called neha.png, sent as image/png, whose bytes were a web page. Unrestricted upload of a file with a dangerous type, CWE-434, is number 12 in MITRE’s 2025 CWE Top 25, because an upload is input that the application stores and later hands to browsers, to other users and to its own tools.

Secure file uploads come down to four decisions: what to accept, where to store it, how to serve it, and what to check inside it. You will make each one for the canteen app, from the photo form to downloads of private files.

An upload is input you will serve back

Every part of an upload comes from the person sending it: the bytes, the file name and the Content-Type that names a type. OWASP’s File Upload Cheat Sheet says the content type “cannot be trusted, as it is trivial to spoof”. Secure file uploads therefore decide the type from the bytes, against a short list of the formats the feature needs. The program below sends six uploads to a weak handler, which believes the name and the header, and to a fixed one, which reads the first bytes, allows only PNG and JPEG, and names the stored file itself:

Six uploads to a photo form, weak and fixed JavaScript · validate/upload_validate.mjs
// Six uploads to a profile-photo form, accepted by a weak handler that believes the file name and the Content-Type,
// and by a fixed one that reads the file's first bytes, keeps an allowlist, and names the stored file itself.
const bytes = (...parts) => new Uint8Array(parts.flatMap((p) => (typeof p === 'string' ? [...p].map((c) => c.charCodeAt(0)) : p)));

// Signatures from the WHATWG MIME Sniffing standard (the first bytes of each format).
const SIGNATURES = [
  { type: 'image/png', ext: 'png', magic: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
  { type: 'image/jpeg', ext: 'jpg', magic: [0xff, 0xd8, 0xff] },
  { type: 'image/gif', ext: 'gif', magic: [0x47, 0x49, 0x46, 0x38] },
  { type: 'application/pdf', ext: 'pdf', magic: [0x25, 0x50, 0x44, 0x46, 0x2d] },
  { type: 'application/zip', ext: 'zip', magic: [0x50, 0x4b, 0x03, 0x04] },
];
function sniff(data) {
  const hit = SIGNATURES.find((s) => s.magic.every((b, i) => data[i] === b));
  return hit ?? { type: 'unknown', ext: null };
}

const UPLOADS = [
  { name: 'asha.png', contentType: 'image/png', data: bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 'rest of a PNG') },
  { name: 'ravi.jpg', contentType: 'text/html', data: bytes([0xff, 0xd8, 0xff, 0xe0], 'rest of a JPEG') },
  { name: 'neha.png', contentType: 'image/png', data: bytes('<!DOCTYPE html><p>a web page, not a photo</p>') },
  { name: 'kiran.svg', contentType: 'image/svg+xml', data: bytes('<svg xmlns="http://www.w3.org/2000/svg"><circle r="4"/></svg>') },
  { name: 'Meena Photo (1).PNG', contentType: 'image/png', data: bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 'rest of a PNG') },
  { name: 'arjun.png', contentType: 'image/png', data: bytes('%PDF-1.7 a document, not a photo') },
];

function weakAccept(upload) {
  const ext = upload.name.split('.').pop().toLowerCase();
  if (!['png', 'jpg', 'jpeg', 'gif', 'svg'].includes(ext)) return { ok: false, reason: `.${ext} not allowed` };
  return { ok: true, stored: `uploads/${upload.name}`, servedAs: upload.contentType };
}

const ALLOWED = new Set(['image/png', 'image/jpeg']);
function makeFixedAccept(newId) {
  return function fixedAccept(upload) {
    if (upload.data.length > 2 * 1024 * 1024) return { ok: false, reason: 'larger than 2 MB' };
    const found = sniff(upload.data);
    if (!ALLOWED.has(found.type)) return { ok: false, reason: `bytes are ${found.type}, not a PNG or JPEG` };
    const ext = upload.name.split('.').pop().toLowerCase();
    const named = ext === 'jpeg' ? 'jpg' : ext;
    if (named !== found.ext) return { ok: false, reason: `name says .${ext}, bytes say ${found.type}` };
    return { ok: true, stored: `avatars/${newId()}.${found.ext}`, servedAs: found.type };
  };
}

let n = 0;
const fixedAccept = makeFixedAccept(() => `f${String(++n).padStart(4, '0')}`); // production: crypto.randomUUID()
const show = (r) => (r.ok ? `kept as ${r.stored}, served as ${r.servedAs}` : `refused: ${r.reason}`);
for (const u of UPLOADS) {
  console.log(`${u.name} (sent as ${u.contentType}, bytes: ${sniff(u.data).type})`);
  console.log(`  weak : ${show(weakAccept(u))}`);
  console.log(`  fixed: ${show(fixedAccept(u))}`);
}

Output

asha.png (sent as image/png, bytes: image/png)
  weak : kept as uploads/asha.png, served as image/png
  fixed: kept as avatars/f0001.png, served as image/png
ravi.jpg (sent as text/html, bytes: image/jpeg)
  weak : kept as uploads/ravi.jpg, served as text/html
  fixed: kept as avatars/f0002.jpg, served as image/jpeg
neha.png (sent as image/png, bytes: unknown)
  weak : kept as uploads/neha.png, served as image/png
  fixed: refused: bytes are unknown, not a PNG or JPEG
kiran.svg (sent as image/svg+xml, bytes: unknown)
  weak : kept as uploads/kiran.svg, served as image/svg+xml
  fixed: refused: bytes are unknown, not a PNG or JPEG
Meena Photo (1).PNG (sent as image/png, bytes: image/png)
  weak : kept as uploads/Meena Photo (1).PNG, served as image/png
  fixed: kept as avatars/f0003.png, served as image/png
arjun.png (sent as image/png, bytes: application/pdf)
  weak : kept as uploads/arjun.png, served as image/png
  fixed: refused: bytes are application/pdf, not a PNG or JPEG

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node upload_validate.mjs

The weak handler kept all six under the names the students chose, a web page and an SVG drawing among them. The fixed one kept three, each under a name it made, and refused the page, the SVG and a PDF named .png. It also kept the JPEG that arrived labelled text/html, because the label decides nothing. Each decision belongs to one place in the app:

Interactive · step by step Where one profile photo goes, and what each place decides Step through one upload from the form to the page that shows it. Each step is one of the four decisions.
Where one profile photo goes, and what each place decides, step 4 of 4. Showing Student app, Upload API, Private storage, Uploads origin, App's page. In focus: Uploads origin, App's page.Student appsends asha.pngUpload APIchecks the bytesPrivate storagenames it makes itselfUploads originfiles.exampleApp's pageapp.exampleimg src=files.example/…

The drawing shows the whole scene; its 4 steps are listed below.

Every step as text
  1. Accept. The app sends the photo with the name and type the student's phone chose. The API reads the file's first bytes, finds a PNG, and checks the size against 2 MB.
  2. Store. The API stores the bytes under a name it made, avatars/f0001.png, in storage that runs nothing and is not a folder of the web server. The student's own file name is kept only as text in a record.
  3. Serve. The photo is served from an origin of its own, with the type found from its bytes, nosniff and a sandbox policy, so a file that is not what it seemed cannot run as part of the app.
  4. Show. The app's page shows the photo from the other origin in an img element. A private file, such as an ID card, also needs its owner's session on every request.

The four decisions, each in one sentence:

  • Accept: check the bytes against an allowlist of formats you need, and a size limit, before anything else.
  • Store: keep files under names the server makes, in storage where nothing can run them.
  • Serve: send downloads from a separate origin with nosniff, a sandbox policy and, for documents, as attachments.
  • Check inside: limit archives and images before unpacking them, rebuild images from their pixels, and scan files in quarantine: Keeping an uploaded file stored but unavailable until its checks, such as an antivirus scan, have finished. Nothing serves a file in quarantine, and a file that fails its checks is deleted from there. first.

A file signature: The fixed bytes at the start of a file that name its format, such as 89 50 4E 47 0D 0A 1A 0A for a PNG or FF D8 FF for a JPEG. An upload check compares them with an allowlist instead of trusting the name or the Content-Type. is what makes the first decision possible: most formats begin with fixed bytes that name them. None of the four decisions relies on the others being perfect. A file that slips past the first check still lands under a name the server made, is still served with headers that keep it from running, and still waits in quarantine until its scan is done.

Checkpoint

Check: Four decisions about every upload

  1. Question 1 of 2 In the run, a file named ravi.jpg arrived with the Content-Type text/html, and its bytes started with FF D8 FF. What did the fixed handler do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Kept it as image/jpeg under a name it made, because the bytes decide

    The name and the Content-Type both come from the uploader. The fixed handler read the first bytes, found a JPEG on its allowlist, and stored the file under a key it made.

  2. Question 2 of 2 Which of these are among the four decisions every upload feature makes? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • What to accept, from the bytes and a size limit
    • How to serve it, from a separate origin with safe headers
    • Where to store it, under a name the server makes

    Accept, store, serve, and check inside: each decision protects even when another one misses something.

Flashcards · 4 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. Why is an upload input you will serve back, and which parts can you not trust?

    The bytes, the file name and the Content-Type all come from the person sending the file, and OWASP's File Upload Cheat Sheet calls the content type trivial to spoof. Decide the type from the bytes against a short list of the formats the feature needs. Unrestricted upload of a dangerous file type is CWE-434, 12th in MITRE's 2025 CWE Top 25.

  2. What are the four decisions of secure file uploads?

    Accept: check the bytes against an allowlist of formats and a size limit before anything else. Store: keep files under names the server makes, in storage where nothing can run them. Serve: from a separate origin, with nosniff, a sandbox policy and, for documents, as attachments. Check inside: limit archives and images before unpacking them, rebuild images from their pixels, and scan files in quarantine first.

  3. What is a file signature, and why does it beat the label?

    Most formats begin with fixed bytes that name them, so the handler can read the first bytes and decide the type itself. In the lesson the fixed handler kept a JPEG that arrived labelled text/html, because the label decides nothing, and refused a PDF named .png.

  4. Why does none of the four upload decisions rely on the others being perfect?

    A file that slips past the first check still lands under a name the server made, is still served with headers that keep it from running, and still waits in quarantine until its scan is done.

Check the bytes against an allowlist, not the name or the header Pro: opens with a Pro, Premium or Ultimate pass

In this part:

  • Store under a name you made, where nothing runs
  • Serve uploads from another origin, as downloads where you can
  • Archives and images: limits before any work
  • Scan in quarantine, and clean up when anything fails
  • Practise: accept a photo and serve a file
    • Accept a profile photo by its bytes, and serve files safely
  • Interview questions
    • Why should an upload handler never trust a file's Content-Type or its name, and what should it check instead?
    • Design the profile-photo upload of an app with 2 million users: what is accepted, where files live, how they are served, and how you test each part.
    • Vendors want to import their menus as one ZIP of CSV files and photos. What do you check before, during and after unpacking, and what do you refuse?
    • Students upload ID cards and fee receipts that only they and the college office may see. Design how these files are stored, served and shared, including links that the office sends by email.
  • Key takeaways

1 diagram · 3 runnable examples (JavaScript, Python) · 1 exercise with sample tests · 3 interview answers · about 31 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Terms in this lesson

file signature
The fixed bytes at the start of a file that name its format, such as 89 50 4E 47 0D 0A 1A 0A for a PNG or FF D8 FF for a JPEG. An upload check compares them with an allowlist instead of trusting the name or the Content-Type.
quarantine
Keeping an uploaded file stored but unavailable until its checks, such as an antivirus scan, have finished. Nothing serves a file in quarantine, and a file that fails its checks is deleted from there.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.