Skip to content
Foxipa

Suggest an edit

Tell us what should be corrected, clarified, or improved. Submission will be connected to Foxipa’s contribution service later.

No information is sent anywhere yet. Your suggestion is only prepared in your browser.

Learning

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.

Levels: Beginner ~30 min read

Basic

What you will learn

  1. Where Content-Type comes from: the types table, default_type, and the per-request lookup order.

  2. Extending and overriding the table — plus the charset layer for text families.

  3. The header pair that decides render-vs-download ( Content-Disposition), and a live demo.

  4. Types for proxied responses (preview of Module 4) and why proxy_hide_header + add_header re-enters here.

  5. 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:

text
1. extension of $uri found in the effective types table?  -> that value
2. charset applies to this type?                          -> append "; charset=…"
3. else                                                   -> default_type

The 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:

bash
$ 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_type

Two 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 .jpg is served as image/jpeg. Deterministic, fast, occasionally surprising.

  • types is per-context (http/server/location) and follows Lesson 2.5's replace-ladder: a types {} block in a location replaces the inherited table for that location (it's the same "one value wins" personality), while multiple types blocks 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):

nginx
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:

nginx
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:

text
inline; filename="x.pdf"   → render if you can (default behavior)
attachment; filename="x.pdf" → download, always

The canonical idiom is a downloads location — a door that should never render:

nginx
location /downloads/ {
    alias www/main/docs/;
    add_header  Content-Disposition  attachment  always;
}
bash
$ 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: attachment

The 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 charset per charset_types only 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:

  1. Never map upload extensions to executable types. Serve uploads from a dedicated location whose types you control — commonly only images — and let everything else fall to default_type application/octet-stream + Content-Disposition: attachment.

  2. Send X-Content-Type-Options: nosniff so browsers stop content-sniffing around your declaration:

    nginx
       add_header  X-Content-Type-Options  nosniff  always;

    ("always" — the header must survive error responses too, or attackers aim at the error paths.)

  3. 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

  1. Tour the three stops (§1) with the direct probes shown above — then invent stop-2 cases: does style.css get the charset? Does payload.bin? Why (check charset_types)?

  2. Add a type. Give .md files text/markdown via a local types {} block, drop a README.md into /var/www/main/, reload, curl it. Now remove .md from the table and watch it fall to application/octet-stream — a download instead of text.

  3. The disposition A/B. Fetch the same PDF from /docs/handbook.pdf and /downloads/handbook.pdf; diff the headers. Then break it: add another add_header to the downloads location and watch Content-Disposition vanish (Lesson 2.5, on camera). Restore.

  4. 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
nginx
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 the types table → charset suffix for text families → default_type fallback. No sniffing, ever.

  • types is per-context with replace semantics; extend deliberately, and consider shrinking it as a hardening move.

  • Content-Disposition is the render/download switch — and it's an add_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 the types table.

  • For user content: controlled types + nosniff + disposition + dotfile denial — four small levers that close a whole attack class.

Was this page helpful?

Mark this learning resource complete to track your learning progress.