3.3 MIME Types, Charset, and Content Negotiation
One header decides whether the browser renders, downloads, or executes your bytes. NGINX decides it by extension — and hands you three levers to take control.
Basic
What you will learn
Where
Content-Typecomes from: thetypestable,default_type, and the per-request lookup order.Extending and overriding the table — plus the
charsetlayer for text families.The header pair that decides render-vs-download (
Content-Disposition), and a live demo.Types for proxied responses (preview of Module 4) and why
proxy_hide_header+add_headerre-enters here.The security dimension: sniffing,
X-Content-Type-Options, and dangerous types by extension.
1. Where
Content-Type comes from
For a static file, NGINX runs a per-request lookup with exactly three stops:
1. extension of $uri found in the effective types table? -> that value
2. charset applies to this type? -> append "; charset=…"
3. else -> default_typeThe
types table is normally
include mime.types; — ~400 lines of
extension type; pairs in a
types {} block (which is why it's an ordinary include: Lesson 2.6).
default_type catches everything the table misses. A typical config sets both deliberately, and the probes below walk each stop:
$ curl -sI -H 'Host: static.example.test' http://127.0.0.1:8300/about.html | grep Content-Type
Content-Type: text/html; charset=utf-8 # stop 1 + stop 2
$ curl -s -o /dev/null -D - -H 'Host: static.example.test' \
http://127.0.0.1:8300/data/users.json | grep -i content-type
Content-Type: application/json; charset=utf-8 # extension match + charset
$ curl -s -o /dev/null -D - -H 'Host: static.example.test' \
http://127.0.0.1:8300/docs/payload.bin | grep -i content-type
Content-Type: application/octet-stream # stop 3: default_typeTwo structural facts worth internalizing:
The lookup is on the file's extension, not its content. NGINX does not sniff bytes (that's browsers' bad habit — see §5). A PDF renamed
.jpgis served asimage/jpeg. Deterministic, fast, occasionally surprising.typesis per-context (http/server/location) and follows Lesson 2.5's replace-ladder: atypes {}block in a location replaces the inherited table for that location (it's the same "one value wins" personality), while multipletypesblocks at the same level simply combine — merging happens only within one level's own blocks.
2. Extending and overriding
Adding types
The sanctioned shape is a small local block (extension keys merge into the effective table for that context):
http {
include mime.types; # the ~400-entry baseline
types {
application/font-woff2 woff2; # add/override locally
}
}Real-world uses: vendor formats (
.woff2 on older builds), internal manifests (
application/manifest+json manifest), dataset extensions, or
removing risk (a hardened
types that deliberately omits
application/x-httpd-php-style mappings — if the extension can't resolve,
default_type application/octet-stream serves it as inert bytes).
The
charset layer
charset utf-8; appends
; charset=utf-8 — but only for types listed in
charset_types (html, xml, plain, css, json, and friends by default). That's why the JSON above got a charset and the PNG never would:
charset utf-8;
charset_types text/html text/xml text/plain text/css application/json;Charset matters more than it looks: a missing declaration invites the browser's
locale-dependent guess (the mojibake you've seen on pages with em-dashes), and JSON with a charset changes strict parsers' behavior (
application/json is UTF-8 by spec; the suffix is belt-and-suspenders).
3. Render vs download:
Content-Disposition
Content-Type says
what the bytes are;
Content-Disposition says what the browser should
do with them:
inline; filename="x.pdf" → render if you can (default behavior)
attachment; filename="x.pdf" → download, alwaysThe canonical idiom is a downloads location — a door that should never render:
location /downloads/ {
alias www/main/docs/;
add_header Content-Disposition attachment always;
}$ curl -sI -H 'Host: static.example.test' \
http://127.0.0.1:8300/downloads/handbook.pdf | grep -iE 'content-(type|disposition)'
Content-Type: application/pdf
Content-Disposition: attachmentThe same PDF served from
/docs/handbook.pdf (root-mapped, no header) renders inline. Same bytes, two behaviors, one header. Production variants:
attachment; filename="report-2026.csv" for branded downloads, and the reverse guard — never let user-uploaded content be
inline with an executable type (§5).
4. Types for proxied responses (preview)
When Module 4's
proxy_pass enters the picture,
Content-Type comes from the
backend, not the extension — the
types table only governs NGINX-originated bodies. The professional levers you'll carry back here:
proxy_hide_header Content-Type;— strip the backend's opinion (rare, but decisive when a misconfigured app labels JSON as HTML);add_header Content-Type application/json always;— impose yours (mindful again of array-cancellation);NGINX still appends
charsetpercharset_typesonly for its own generated bodies; proxied bytes pass through verbatim by default.
The full treatment lands in Lesson 4.2's header-hygiene section — filed here so you know where the lever lives.
5. The security dimension
MIME confusion is an attack class. A browser fed
Content-Type: text/html on user-uploaded bytes executes scripts in your origin; fed
application/octet-stream, it merely saves them. Hence the hard rules for any user-content location:
Never map upload extensions to executable types. Serve uploads from a dedicated location whose
typesyou control — commonly only images — and let everything else fall todefault_type application/octet-stream+Content-Disposition: attachment.Send
X-Content-Type-Options: nosniffso browsers stop content-sniffing around your declaration:nginxadd_header X-Content-Type-Options nosniff always;("always" — the header must survive error responses too, or attackers aim at the error paths.)
Consider a separate origin for user content (the cookie-boundary argument) — beyond NGINX config, but the reason CDNs exist.
The dotfile defense (
location ~ /\.(?!well-known) { deny all; }, Lesson 3.5's deep dive) is the sibling control: content typing is about
how bytes are treated, dotfile denial about
which files are reachable at all.
And content negotiation? NGINX’s model is extension-based — it does not read the browser’s
Accept header to pick a file variant. If you need Accept-driven negotiation (serve
image/webp to clients that ask for it), the idiom is a
map on
that rewrites the URI to the right pre-rendered variant — policy in config, files on disk.
Try it yourself
Tour the three stops (§1) with the direct probes shown above — then invent stop-2 cases: does
style.cssget the charset? Doespayload.bin? Why (checkcharset_types)?Add a type. Give
.mdfilestext/markdownvia a localtypes {}block, drop aREADME.mdinto/var/www/main/, reload, curl it. Now remove.mdfrom the table and watch it fall toapplication/octet-stream— a download instead of text.The disposition A/B. Fetch the same PDF from
/docs/handbook.pdfand/downloads/handbook.pdf; diff the headers. Then break it: add anotheradd_headerto the downloads location and watchContent-Dispositionvanish (Lesson 2.5, on camera). Restore.Hardening pass. Add
X-Content-Type-Options: nosniff always;at server level, then add any header in a location and watch nosniff disappear there — then fix it with a snippets include, exactly as Lesson 2.6 taught.
Q&A
Q1. A
.json file returns
Content-Type: application/json; charset=utf-8 while a
.png returns bare
image/png. Why the asymmetry?
Answer
charset only appends for types in
charset_types (text families). PNG is binary; a charset parameter on binary types is meaningless noise, and the default list correctly excludes it.
Q2. You must serve
.xyz internal data files as downloads with a fixed name. Two directives?
Answer
Let
.xyz fall to
default_type application/octet-stream (or map it explicitly), and
add_header Content-Disposition 'attachment; filename="data.xyz"' always; in its location. (Type says "inert bytes"; disposition says "save as".)
Q3. Why does a
types {} block in a location potentially
shrink the types available there?
Answer
types follows the replace-ladder: a block at a deeper context replaces the inherited table for that context (merging happens only within one level's own blocks). A one-entry
types in a location makes everything else in that location fall to
default_type.
Q4. User uploads live in
/uploads/. Sketch the three-line hardening.
Answer
location /uploads/ {
types { } # or images only
default_type application/octet-stream; # inert fallback
add_header X-Content-Type-Options nosniff always;
add_header Content-Disposition attachment always; # optional but strict
}
— plus dotfile denial and an upload-only filesystem permission model.
Takeaways
Content-Type= extension lookup in thetypestable →charsetsuffix for text families →default_typefallback. No sniffing, ever.typesis per-context with replace semantics; extend deliberately, and consider shrinking it as a hardening move.Content-Dispositionis the render/download switch — and it's anadd_header, so Lesson 2.5's cancellation law owns it too.Proxied content-type control belongs to
proxy_hide_header/add_header(Module 4), not thetypestable.For user content: controlled types +
nosniff+ disposition + dotfile denial — four small levers that close a whole attack class.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.