X-XSS-Protection: Why to Set It to 0

This guide is part of the Deprecated Security Headers & Legacy Browser Support reference. X-XSS-Protection controlled a browser feature called the XSS Auditor — a reflected-XSS heuristic that lived in WebKit and Blink. That feature is gone: Chrome removed the Auditor in version 78 (2019), Edge dropped it when it moved to Chromium, and neither Firefox nor Safari ever shipped it. The header now has exactly one correct value, 0, which disables the residual behaviour on any engine that still reads it. The only real replacement for reflected-XSS defence is a strict Content-Security-Policy, and this page shows how to set the header off cleanly while pointing your defence at CSP.

Timeline of the XSS Auditor's rise and removal A horizontal timeline from 2010 to 2020 marking when the XSS Auditor shipped, when filtered mode was found to leak, and when Chrome removed it. 2010 Auditor ships in Chrome/WebKit 2016 XS-leak & bypass reports mount 2018 Edge drops filter, Chrome default off 2019 Chrome 78 removes Auditor A nine-year feature retired for causing more harm than it prevented
The XSS Auditor was disabled by default, then removed outright, because its false positives became exploitable side channels.

Configuration Syntax & Exact Values

The header takes one of a small set of tokens. Only 0 is correct today. Every other value either enables removed behaviour or actively introduces risk on the engines that still honour it.

X-XSS-Protection: 0

Directive breakdown of every historical value and why to avoid all but 0:

Value Historical meaning Verdict
0 Disable the browser XSS filter entirely Correct. Neutralises residual Auditor behaviour; no downside.
1 Enable the filter; sanitise the suspected reflection Enables removed/risky heuristic; can corrupt legitimate markup.
1; mode=block Enable the filter; block the whole page on detection Harmful. The block action became an exploitable XS-leak oracle.
1; report=<uri> Enable and POST a violation report (Chromium-only) Depends on the removed engine; reports nothing on modern browsers.
(header absent) Engine default Fine on modern browsers, but explicit 0 documents intent and covers legacy engines defaulting to 1.

The critical point about 1; mode=block: when the Auditor believed it detected an injected script, it rendered about:blank instead of the page. An attacker who could inject a benign, known string could make the Auditor falsely trigger on chosen substrings of the response, and by observing whether the page blocked or loaded, read out secret data one character at a time — a classic cross-site leak. mode=block turned a defensive feature into an information-disclosure primitive.

How mode=block became a cross-site leak oracle A sequence showing an attacker page inducing a false Auditor trigger and reading a secret bit from whether the victim page blocked or rendered. Attacker page Victim origin Load victim URL with crafted query reflecting a guessed secret prefix Response reflects query; Auditor inspects it Guess matches reflection? Auditor triggers -> page BLOCKED Blocked = bit correct Rendered = bit wrong Repeat per character to exfiltrate the secret
The block/render distinction is an observable side channel; an attacker brute-forces secret bytes by watching which outcome occurs.

Server-Side Configuration

Emit X-XSS-Protection: 0 on every response. In each platform below the header is set unconditionally so it also lands on error pages, where injected reflections frequently occur.

Nginx

server {
    listen 443 ssl;
    server_name example.com;

    # "always" attaches the header to 4xx/5xx responses too, not just 2xx/3xx.
    # Error pages reflect input as often as normal pages, so they must be covered.
    add_header X-XSS-Protection "0" always;

    # The real reflected-XSS control belongs here alongside it:
    add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'none'" always;
}

See the Nginx security headers guide for the module-load and map patterns used to apply this across many server blocks.

Apache

# Requires mod_headers. "always" sets the header in the fixup phase so it is
# emitted on error responses too; without it, ErrorDocument pages omit it.
Header always set X-XSS-Protection "0"
Header always set Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'none'"

The Apache hardening guide covers placing this in a VirtualHost versus .htaccess and merge order with inherited configs.

Cloudflare

{
  "action": "rewrite",
  "expression": "http.host eq \"example.com\"",
  "action_parameters": {
    "headers": {
      "X-XSS-Protection": { "operation": "set", "value": "0" }
    }
  }
}

Use a Transform Rule (Modify Response Header) with a set operation so the value is applied at the edge regardless of what origin sends. Details in the Cloudflare headers guide.

Node / Express (Helmet)

const express = require('express');
const helmet = require('helmet');
const app = express();

// Helmet sets X-XSS-Protection: 0 by default since v5 — this is intentional.
// The xXssProtection middleware ONLY ever writes 0; there is no "enable" option.
app.use(helmet.xXssProtection());

// Do the actual work in CSP:
app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    objectSrc: ["'none'"],
    baseUri: ["'none'"],
  },
}));

Modern Helmet already sends 0 out of the box; older tutorials that show 1; mode=block are outdated — remove any such override.

Django / FastAPI

# Django: SECURE_BROWSER_XSS_FILTER was removed in Django 3.0 because it emitted
# the harmful "1; mode=block". Do NOT reintroduce it. Set 0 via middleware:
class XssProtectionMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        response = self.get_response(request)
        response["X-XSS-Protection"] = "0"
        return response

Django deliberately dropped its SECURE_BROWSER_XSS_FILTER setting; the Django/FastAPI middleware guide shows wiring a plain 0 header and a real CSP alongside it.

Diagnostic & Verification Steps

Confirm the header value at the edge the browser actually reaches.

curl -sI https://example.com/ | grep -i x-xss-protection

Expected output:

x-xss-protection: 0

If you see 1; mode=block or 1, an outdated config or a proxy default is still injecting it — find and remove that source. Verify error pages too, since they reflect input:

curl -sI 'https://example.com/does-not-exist?q=<script>' | grep -i x-xss-protection

Expected output on a 404 response (the header must still be present because of always):

x-xss-protection: 0

In DevTools → Network, select the document request and read the Response Headers pane; X-XSS-Protection: 0 should appear. Chrome will not act on any value here — the Auditor is gone — but confirming 0 documents that no legacy engine in your user base will run the filter. There is no browser console warning for this header, so the curl checks are authoritative.

Decision tree for choosing the header value A flowchart that always resolves to setting X-XSS-Protection to 0 and relying on Content-Security-Policy for reflected-XSS defence. Setting this header? Do you need reflected-XSS defence? (you always do) Use it from THIS header? Feature removed from browsers -> No X-XSS-Protection: 0 Content-Security-Policy
Every path resolves the same way: disable the header with 0 and place reflected-XSS defence in CSP.

Edge Cases, Security Implications & Safe Rollback

Conclusion

Apply X-XSS-Protection: 0 in staging first and confirm it with curl -sI on both normal and error responses. Because the value only disables a removed feature there is no enforcement risk, so the substantive rollout is the CSP that replaces it: deploy Content-Security-Policy-Report-Only with a short reporting window, triage the violation stream, then promote to an enforcing Content-Security-Policy in production. The header goes to 0 on day one; the real defence lands when CSP enforces.

Frequently Asked Questions

Why is 0 recommended instead of just omitting the header? On modern browsers omitting it and sending 0 are equivalent, because the Auditor is gone. Setting 0 explicitly is still preferred: it documents intent, overrides any legacy engine or proxy that defaults to 1, and prevents a well-meaning teammate or scanner from reintroducing 1; mode=block.

What was actually wrong with 1; mode=block? The block action rendered a blank page whenever the Auditor thought it saw a reflected script. Attackers could force false positives on chosen substrings and observe whether the page blocked, turning it into a cross-site leak oracle that exfiltrated secrets byte by byte. It was a defence that created a new vulnerability.

Does any current browser still run the XSS Auditor? No. Chrome removed it in version 78 (2019), Edge dropped it in its Chromium transition, and Firefox and Safari never implemented it. The header is inert on every current engine; 0 matters only for the shrinking set of legacy browsers that still read it.

If the header does nothing now, what stops reflected XSS? A strict Content-Security-Policy with a script-src that uses nonces or hashes and excludes 'unsafe-inline', combined with correct output encoding on the server. CSP is enforced by every modern browser and, unlike the Auditor, does not depend on fragile string-matching heuristics.

Will a security scanner mark a missing X-XSS-Protection as a failure? Some outdated scanner rules still do, and a few even suggest 1; mode=block. Send 0 to satisfy the presence check without enabling harmful behaviour, and treat any recommendation to enable the filter as a stale rule that predates the feature’s removal.