How to check X-Content-Type-Options

Send a HEAD request and read one header: curl -sI https://example.org/ | grep -i x-content-type-options. A server that blocks MIME sniffing answers x-content-type-options: nosniff. Empty output means the header is absent, and the browser is then free to guess the type of a response and run a text file as script.

Why check this

MIME sniffing is the browser reading the first bytes of a response and overriding the content type the server declared. With nosniff the browser stops guessing and honours the declared type for scripts and stylesheets. Without it, a file stored as text/plain that begins with HTML markup can be rendered as HTML on your own origin.

Run the check on every route that returns a file a user supplied: avatar uploads, attachment downloads, exported reports, generated invoices. Run it again after a CDN or a storage bucket goes in front of those routes, because the header now comes from whichever layer writes the response. The homepage almost always carries nosniff. The download route added three sprints later usually does not, and step 3 finds that gap.

Prerequisites

import { createServer } from 'node:http';

const upload = Buffer.from('<script>document.title="sniffed"</script>');

createServer((req, res) => {
  if (req.url === '/') {
    res.writeHead(200, { 'content-type': 'text/html', 'x-content-type-options': 'nosniff' });
    res.end('<h1>home</h1>');
  } else if (req.url === '/files/notes.txt') {
    res.writeHead(200, { 'content-type': 'text/plain' });
    res.end(upload);
  } else {
    res.writeHead(404).end();
  }
}).listen(8080, () => console.log('listening on 8080'));

Steps

  1. Step 1.

    Read the header on a site that sets it, together with the content type it protects.

    curl -sI https://www.cloudflare.com/ | grep -iE '^HTTP|^content-type|^x-content-type-options'
    
    HTTP/2 103
    HTTP/2 200
    content-type: text/html; charset=utf-8
    x-content-type-options: nosniff

    Two status lines appear because this host sends a 103 early hints response before the 200. Read the headers that follow the last status line.

  2. Step 2.

    Count the header on a site that omits it, so that absence is a number and not an empty screen.

    curl -sI https://example.com/ | grep -ci 'x-content-type-options'
    
    0
  3. Step 3.

    Start the local server, then read the headers of the file route rather than the homepage.

    curl -sD - -o /dev/null http://localhost:8080/files/notes.txt | grep -iE '^HTTP|content-type|x-content-type-options'
    
    HTTP/1.1 200 OK
    content-type: text/plain

    The homepage of the same server answers x-content-type-options: nosniff. The file route does not, which is the finding.

  4. Step 4.

    Print the body of that file route to see what a browser would be sniffing.

    curl -s http://localhost:8080/files/notes.txt
    
    <script>document.title="sniffed"</script>

    Declared text/plain, actual content HTML, no nosniff. Stop the server with Ctrl+C once you have this line.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | x-content-type-options: nosniff | The browser honours the declared type on this route | Nothing. Repeat on the upload and download routes. | | Nothing at all | Sniffing is allowed on this response | Add the header at the layer that writes the response, then re-run step 2. | | nosniff on HTML, absent under /files/ | The header is set on one handler, not globally | Move it to the shared response middleware instead of to each route. | | x-content-type-options: NOSNIFF | Still valid, the token is case insensitive | Nothing. Match with grep -i so your test does not report a false miss. |

Common mistakes

Sign: The homepage passes the check and a stored XSS report still lands weeks later.Cause: The header was added to the HTML handler only. Uploaded files are served by a separate route, often by a storage bucket or a CDN that writes its own response headers, and that path was never tested.
Sign: nosniff is present and a script suddenly fails to load in the browser.Cause: nosniff also blocks a script or a stylesheet whose declared type is wrong. A script served as text/plain is refused rather than guessed. That is the header working, so fix the content type instead of removing the header.
Sign: A scanner reports the header missing on a 404 or a 500.Cause: Error responses are generated by a different code path than successful ones and often skip the response middleware. Test one failing status as well as one 200.

What to check next

FAQ

Is any value other than nosniff valid?

No. nosniff is the only token the specification defines. Anything else is ignored, so a typo such as no-sniff leaves sniffing enabled while the header still looks present in a scan report.

Does the header protect every response type?

It constrains two request destinations, scripts and stylesheets. For those, a wrong declared type makes the browser refuse the resource. Browsers also apply it to downloads, which stops a file from being displayed inline as HTML.

Can I check it in Chrome DevTools instead?

Open DevTools, Network tab, click the request, then the Headers panel and read Response Headers. That works for one request. The curl form in step 3 is what you put in a test that runs on every build.

Why does the check fail only on staging?

The header is usually added by a proxy or a CDN rule that exists in one environment and not the other. Compare both environments with step 1 before you edit any application code.

Verified

Verified by Maks Vernycurl 8.21.0node 22.23.2

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

basic5 minpublished updated Maks Verny