Skip to content
pageinspection

What a browser does when your content type is wrong

Browsers used to second-guess the content type you declared, by looking at the first few bytes of a file. That guessing is how an uploaded image becomes a script, and one header switches it off everywhere.

The short answer

X-Content-Type-Options: nosniff tells the browser to trust your declared Content-Type instead of guessing from the bytes. Sniffing exists because early servers mislabelled files; the cost is that an uploaded file served as text can be executed as script. The one way it breaks a site is a server declaring the wrong type for assets that were working anyway.

RefinementAudit check · X-Content-Type-Options

What sniffing was for, and what it enabled

For years a large share of the web declared everything as text/plain or nothing at all, because servers were misconfigured and nobody noticed. Browsers compensated by examining the content: a response that began with <html was treated as HTML whatever the header said, and a file starting with the JPEG magic bytes was treated as an image. That made broken sites work, which is why every browser did it.

The security consequence appears as soon as a site accepts uploads. A file that a visitor uploaded is served back by your application with a declared type. If a browser is willing to override that declaration based on the bytes, then an attacker uploads a file whose contents are HTML with a script in it, your application serves it as an image, and the browser sniffs it, decides it is HTML, and runs the script in your origin.

X-Content-Type-Options: nosniff ends the negotiation. The declared type is used, and if it is wrong the file does not render rather than rendering as something else.

nosniff.txt
# there is one value, and no reason to send anything else
X-Content-Type-Options: nosniff

# nginx, on every response
add_header X-Content-Type-Options "nosniff" always;

# what nosniff enforces, once it is on
declared                      contains          result
----------------------------  ----------------  --------------------
image/png                     HTML and script   not rendered at all
text/plain                    HTML              shown as plain text
application/octet-stream      anything          downloaded, not run
text/css                      not CSS           stylesheet rejected
text/javascript               JavaScript        runs, as intended

The fourth row is the one that breaks sites, and it is worth knowing before you switch this on: nosniff also enforces the type on stylesheets and scripts. A stylesheet served as text/plain works today by being sniffed, and stops working the moment you send this header.

The one way this breaks a working site

Every other header in this group is safe to add blind. This one has a specific failure, and it is worth ten minutes of checking rather than a rollback.

If any of your scripts or stylesheets are served with the wrong content type, they are currently being rescued by sniffing. Turning it off makes the browser refuse them, and a refused stylesheet is an unstyled page while a refused script is a page whose functionality is missing. The symptom looks nothing like a header change.

Where wrong types come from:

  • A static file server with an incomplete type map. Anything it does not recognise gets a default, and newer extensions are the ones missing. A .mjs module or a .webmanifest served as text/plain is common.
  • Files served from object storage. The type is set at upload time and stored with the object, so a bad value is permanent until somebody reuploads. Build pipelines that copy assets to a bucket frequently set nothing.
  • An application route serving a file. A download endpoint or an asset proxy that forgot to set the header, where the default is often application/octet-stream or empty.

The order to do this in: check the types first, then send the header.

Request every script and stylesheet your pages reference and read the content-type on each. Anything that is not text/css or a JavaScript type is a file that will be rejected. Fix those, then add the header, and the change is invisible.

Doing it the other way round means finding out from a visitor that your site is unstyled, and the connection back to a one-line configuration change made that morning is not obvious to anybody.

A header on every response, not just on pages

This is worth checking beyond your HTML. The attack it prevents concerns files your application serves back, so the responses that matter most are your uploads, your static assets and any download endpoint, none of which is a page.

This one needs the full crawl

This is a header on every response, not just on pages, so the useful finding covers your uploads and static assets as well as your HTML.

The instant search on this site audits a single page, so rather than show you a verdict it cannot support, this guide sends you to the place the check actually runs.

Why this one sits below the other headers

It is the only Refinement in this group, and the reason is the precondition. The attack requires that your site serves files somebody else supplied. A site with no uploads, no user avatars, no attachments and no document library has nothing for an attacker to plant, so the header protects against a scenario that does not exist there.

Sniffing has also been narrowed in browsers over the years, which reduces the exposure further without removing it. The combination of a narrow precondition and a shrinking behaviour is what puts this below the framing header rather than beside it.

The rating flips on one fact: if your site accepts uploads and serves them back, treat this as Important and check it today. That is a large category, it includes any site with a profile picture, and the chip cannot know which kind of site you are. It is also the only check in the catalogue where adding the recommended header can break a working page, which is why the guide spends more space on the order of operations than on the header itself.

Checking every asset type before you switch it on

The list you need is every script and stylesheet your pages reference, with the type each one is served as.

check-types.sh
URL=https://example.com/

curl -s "$URL" \
  | grep -oE '(src|href)="[^"]*\.(js|mjs|css)[^"]*"' \
  | sed 's/.*="//;s/"$//' \
  | sort -u \
  | while read -r asset; do
      case "$asset" in
        http*) full="$asset" ;;
        *)     full="https://example.com$asset" ;;
      esac
      type=$(curl -s -I "$full" | grep -i '^content-type:' | cut -d' ' -f2- | tr -d '\r')
      printf '%-24s %s\n' "${type:-NONE}" "$asset"
    done

# and the header itself, on a page and on an asset
curl -s -I "$URL" | grep -i 'x-content-type-options'
curl -s -I https://example.com/uploads/avatar.png | grep -i 'x-content-type-options'

Anything in the first column that is not text/css or a JavaScript type is a file that nosniff will reject. A NONE is the worst case, and it is currently working only because a browser is guessing on your behalf.

Questions this check raises

What does nosniff actually stop?
It stops the browser overriding your declared type based on the content it sees. The attack it closes is a user-uploaded file served with a benign type that the browser sniffs as HTML or JavaScript and executes in your origin.
Can nosniff break my site?
Only if your server was already declaring types incorrectly and sniffing was silently compensating. The usual casualties are stylesheets served as text/plain and scripts served with no type at all. Checking every asset type before switching it on is the whole of the migration.
Is this header enough on its own?
No, and it is not meant to be. It removes one specific class of type-confusion, and it sits below CSP and HSTS in what it prevents. It is on this list because it is a single header with almost no downside once your types are correct.