X-XSS-Protection deprecated

Yes. Run curl -sI https://www.cloudflare.com/ | grep -i '^x-xss-protection:' and it still returns 1; mode=block, but that value blocks nothing now. Chrome removed the filter in version 78, Safari in 15.4, and Firefox never shipped one. Expect 0 or no header, and test script blocking through Content-Security-Policy instead.

Checker offline. Follow the manual steps below, they give the same answer.

Why check this

Run this in the security-header review before a release, and whenever a scanner reports X-XSS-Protection as present, missing or wrong. The failure it prevents is a reflected XSS finding closed as mitigated because the response carries 1; mode=block. Steps 5 to 7 load exactly that response in three current browsers, and the injected script runs in all of them. The reverse failure is cheaper but real: a scan rule that fails the build on a missing header, so a sprint goes to adding a header that no current engine acts on.

Prerequisites

const http = require('node:http');
const routes = {
  '/none': {},
  '/xxp-0': { 'x-xss-protection': '0' },
  '/xxp-1': { 'x-xss-protection': '1' },
  '/xxp-block': { 'x-xss-protection': '1; mode=block' },
  '/csp': { 'content-security-policy': "script-src 'self'" },
  '/csp-hash': { 'content-security-policy': "script-src 'self' 'sha256-KqG8JC2gsc/8am502f2BI51NnjEjvgQJvnG4ApP0yUI='" },
};
http.createServer((req, res) => {
  const url = new URL(req.url, 'http://127.0.0.1:8939');
  const extra = routes[url.pathname];
  if (!extra) {
    res.writeHead(404);
    return res.end();
  }
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', ...extra });
  // Deliberately vulnerable: q goes into the HTML without escaping.
  res.end(`<!doctype html><title>search</title><link rel="icon" href="data:,">
<p>Results for ${url.searchParams.get('q')}</p>`);
}).listen(8939, '127.0.0.1', () => console.log('reflect target on http://127.0.0.1:8939/'));
const puppeteer = require('puppeteer-core');
const [executablePath, browser = 'chrome'] = process.argv.slice(2);
const q = encodeURIComponent('<script>window.injected = true</script>');
const routes = ['/none', '/xxp-0', '/xxp-1', '/xxp-block', '/csp', '/csp-hash'];

(async () => {
  const b = await puppeteer.launch({ executablePath, browser, headless: true });
  console.log(await b.version(), '|', executablePath);
  const page = await b.newPage();
  const messages = [];
  page.on('console', (m) => messages.push(m.text()));
  for (const route of routes) {
    messages.length = 0;
    const res = await page.goto(`http://127.0.0.1:8939${route}?q=${q}`);
    const ran = await page.evaluate(() => window.injected === true);
    const xxp = res.headers()['x-xss-protection'] ?? '(absent)';
    console.log(`${route.padEnd(10)} x-xss-protection: ${xxp.padEnd(13)} injected script ran: ${ran}`);
    for (const m of messages) console.log(`  console: ${m}`);
  }
  await b.close();
})();

Steps

  1. Step 1.

    Read the header on a live response, with the status lines, so a challenge page is not mistaken for the real one.

    curl -sI https://www.cloudflare.com/ | tr -d '\r' | grep -i -E '^HTTP/|^x-xss-protection:'
    
    HTTP/2 103 
    HTTP/2 200 
    x-xss-protection: 1; mode=block

    103 is an Early Hints response and carries no security headers. The header belongs to the 200 that follows. If the second status is anything else, the header you read is not the page's.

  2. Step 2.

    Read the same header on a host that sets it the other way.

    curl -sI https://api.github.com/ | tr -d '\r' | grep -i -E '^HTTP/|^x-xss-protection:'
    
    HTTP/2 200 
    x-xss-protection: 0

    0 switches the filter off. OWASP's recommendation reads: "Do not set this header or explicitly turn it off."

  3. Step 3.

    Count the header on a host that does not send it.

    curl -sI https://example.com/ | tr -d '\r' | grep -ic '^x-xss-protection:'
    
    0

    Absent is the other state OWASP accepts. The Security headers checker adds no row for it, and reports a present header as info.

  4. Step 4.

    Confirm the payload comes back as markup.

    curl -si "http://127.0.0.1:8939/xxp-block?q=%3Cscript%3Ewindow.injected%20%3D%20true%3C%2Fscript%3E" | tr -d '\r' | grep -i -E '^x-xss-protection:|Results for'
    
    x-xss-protection: 1; mode=block
    <p>Results for <script>window.injected = true</script></p>

    The query string is now a live <script> element under the blocking header. That is the reflected XSS the old filter was built to catch.

  5. Step 5.

    Run the probe in Chrome.

    node probe.js "C:/Program Files/Google/Chrome/Application/chrome.exe"
    
    Chrome/152.0.7977.76 | C:/Program Files/Google/Chrome/Application/chrome.exe
    /none      x-xss-protection: (absent)      injected script ran: true
    /xxp-0     x-xss-protection: 0             injected script ran: true
    /xxp-1     x-xss-protection: 1             injected script ran: true
    /xxp-block x-xss-protection: 1; mode=block injected script ran: true
    /csp       x-xss-protection: (absent)      injected script ran: false
    console: Executing inline script violates the following Content Security Policy directive 'script-src 'self''. Either the 'unsafe-inline' keyword, a hash ('sha256-KqG8JC2gsc/8am502f2BI51NnjEjvgQJvnG4ApP0yUI='), or a nonce ('nonce-...') is required to enable inline execution. The action has been blocked.
    /csp-hash  x-xss-protection: (absent)      injected script ran: true

    Line 1 is browser.version() and the binary that launched. Lines 2 to 5 differ only in the header, and the payload set window.injected under all four. The /csp row is the only block, and its console line is the violation Chrome reported. The /csp-hash row lists the hash from that message and runs the payload again. How to check CSP header covers reading a real policy.

  6. Step 6.

    Run the same probe in Brave, a second Chromium engine.

    node probe.js "C:/Program Files/BraveSoftware/Brave-Browser/Application/brave.exe"
    
    Chrome/153.0.8010.37 | C:/Program Files/BraveSoftware/Brave-Browser/Application/brave.exe
    /none      x-xss-protection: (absent)      injected script ran: true
    /xxp-0     x-xss-protection: 0             injected script ran: true
    /xxp-1     x-xss-protection: 1             injected script ran: true
    /xxp-block x-xss-protection: 1; mode=block injected script ran: true
    /csp       x-xss-protection: (absent)      injected script ran: false
    console: Executing inline script violates the following Content Security Policy directive 'script-src 'self''. Either the 'unsafe-inline' keyword, a hash ('sha256-KqG8JC2gsc/8am502f2BI51NnjEjvgQJvnG4ApP0yUI='), or a nonce ('nonce-...') is required to enable inline execution. The action has been blocked.
    /csp-hash  x-xss-protection: (absent)      injected script ran: true

    Brave reports its Chromium version through browser.version(). The six rows match Chrome's.

  7. Step 7.

    Run the probe in Firefox, which needs firefox as the second argument.

    node probe.js "$HOME/.cache/puppeteer/firefox/win64-stable_154.0/core/firefox.exe" firefox
    
    firefox/154.0 | C:/Users/khark/.cache/puppeteer/firefox/win64-stable_154.0/core/firefox.exe
    /none      x-xss-protection: (absent)      injected script ran: true
    /xxp-0     x-xss-protection: 0             injected script ran: true
    /xxp-1     x-xss-protection: 1             injected script ran: true
    /xxp-block x-xss-protection: 1; mode=block injected script ran: true
    /csp       x-xss-protection: (absent)      injected script ran: false
    /csp-hash  x-xss-protection: (absent)      injected script ran: true

    Same verdicts. The /csp row has no console line, although the policy blocked the script.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | x-xss-protection: 1; mode=block | Asks for a filter that current Chrome, Edge, Safari and Firefox do not have | Record it as informational. Do not count it as XSS protection. | | x-xss-protection: 1 | The same request in sanitising mode | Same as the row above. | | x-xss-protection: 0 | The filter is switched off, as OWASP recommends when the header is sent | Accept it. | | No x-xss-protection header | The other state OWASP accepts | Accept it. Do not raise a defect. | | injected script ran: true under any of those values | Output is not escaped, and the header changed nothing | File the reflected XSS. The fix is output encoding, with CSP as the second layer. | | injected script ran: false with a violation | The policy blocked the inline script | Confirm the production script-src has no 'unsafe-inline'. | | injected script ran: true with a hash in script-src | The allowed hash matches the payload | Remove the hash and escape the output instead. |

Common mistakes

Sign: A reflected XSS finding is closed as mitigated because the response sends X-XSS-Protection: 1; mode=block.Cause: The filter that value switches on is gone. Steps 5 to 7 ran the injected script under that exact header in Chrome 152, Brave 1.95.101 and Firefox 154. A header name in a scan report is not a test of behaviour.
Sign: A security-header scan fails the build because X-XSS-Protection is missing.Cause: The rule predates the removals. OWASP recommends not setting the header or sending 0, so the assertion should accept both states and the script check should move to the CSP row.
Sign: The CSP violation is silenced by pasting the hash from the console into script-src, and the XSS works again.Cause: Chrome hashes the script it blocked, and on a reflected page that script is the payload. The value in step 5 is the sha256 of window.injected = true, and the /csp-hash row runs the payload under a policy that lists it.
Sign: A browser test asserts on the CSP violation text and fails in Firefox, though the script was blocked.Cause: Firefox 154 gave puppeteer-core 25.10.0 no console event for the violation in step 7. Assert on the effect, a marker the payload would set, not on console text.

Thresholds

Chrome 78, Edge 17, Safari 15.4

The version in which each engine removed the filter, from the data behind MDN's compatibility table. Firefox is recorded with version_added: false, so it never had one.

Source: https://github.com/mdn/browser-compat-data/blob/main/http/headers/X-XSS-Protection.json
Chrome 78

Chrome's removal entry gives the reason: "The XSS Auditor can introduce cross-site information leaks and mechanisms to bypass the Auditor are widely known."

Source: https://chromestatus.com/feature/5021976655560704

What to check next

FAQ

Is the X-XSS-Protection header deprecated?

Yes. MDN marks it deprecated and non-standard, and its compatibility data lists removal in Chrome 78, Edge 17 and Safari 15.4, with no Firefox version ever. Steps 5 to 7 show the same thing from the other side: the header changed nothing in three current browsers.

What is X-XSS-Protection?

A response header that controlled a browser's built-in reflected-XSS filter. MDN describes it as "a feature of Internet Explorer, Chrome and Safari that stopped pages from loading when they detected reflected cross-site scripting (XSS) attacks." The filter is gone, so the header survives only in configuration.

What does X-XSS-Protection: 1; mode=block do?

In a browser that still had the filter, MDN says "the browser will prevent rendering of the page if an attack is detected." In Chrome 152, Brave 1.95.101 and Firefox 154 it did nothing: the page rendered and the payload ran. MDN also warns that this mode "might be vulnerable to side-channel attacks" on a framable page.

Verified

Verified by Maks Vernycurl 8.21.0Node 22.23.2puppeteer-core 25.10.0Chrome 152.0.7977.76Brave 1.95.101Firefox 154.0

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.

intermediate10 minpublished updated Maks Verny