A while back I reviewed a change where someone had closed a ticket titled “add security headers.” They’d added all of them. Content-Security-Policy, Strict-Transport-Security, X-Frame-Options, the lot. The scanner that had flagged the site went from an ugly F to a clean A. Ticket closed, box checked, everyone moved on.
The CSP they shipped was, roughly, default-src *; script-src 'unsafe-inline' 'unsafe-eval'.
That policy permits a script from anywhere, allows inline scripts, and allows eval. In other words, it permits exactly the things a Content-Security-Policy exists to forbid. The header was present. The scanner saw a Content-Security-Policy and gave points for it. And the site was precisely as exploitable as it had been the day before — except now there was a green checkmark on a dashboard telling everyone the XSS problem was handled.
That is the whole argument, so let me say it plainly: a security header is a string, and whether the string does anything depends entirely on its value, the application behind it, and the browser reading it. Checklists grade presence. Attackers exploit efficacy. Those are not the same axis, and the distance between them is where every real vulnerability lives.
The header that lies to you
Start with CSP, because it’s the worst offender and the most instructive.
Content-Security-Policy is genuinely one of the strongest defenses a web app has. A tight policy can neuter an entire class of cross-site scripting: even if an attacker injects a <script> into your page, the browser refuses to run it because the policy didn’t authorize it. That’s real. That’s worth the considerable effort it takes to deploy.
But the single most common way CSP is deployed is with 'unsafe-inline' in script-src, because that’s what makes it “work” without rewriting the front end. And 'unsafe-inline' is the off switch. Inline scripts are where injected XSS payloads live — a reflected parameter that lands in the page, a stored comment that renders as markup. If your policy allows inline script, the browser will happily execute the attacker’s <script> right alongside your own, because from the policy’s point of view it’s authorized. You have a Content-Security-Policy header. You do not have Content-Security-Policy.
A scanner cannot tell these apart in any way that matters. It sees the header, maybe dings you a point for unsafe-inline if it’s a good scanner, and still calls it “present.” Meanwhile the security review is over, because the checklist said CSP: yes.
Here’s the part that actually keeps me up: a missing CSP is safer than this one. A missing header is an honest F — everyone knows there’s no XSS defense, and it stays on the backlog. A present-but-toothless CSP converts “we have no defense” into “we have a defense” on every report anyone will ever look at. Nobody re-audits a green checkmark. You’ve taken a known gap and hidden it behind a passing grade. That’s not neutral. That’s worse than nothing.
Report-Only is not a policy
The next one is quieter and just as common. There are two CSP headers: Content-Security-Policy, which enforces, and Content-Security-Policy-Report-Only, which enforces nothing. Report-Only exists so you can trial a policy in production — the browser evaluates it, sends you violation reports, and does not block a single thing.
It is a genuinely good tool. It is also, constantly, shipped as the finished product. A team writes a decent policy, deploys it in Report-Only to test it safely, watches the reports for a week, gets busy, and never flips it to enforcing. Six months later the site has a carefully-tuned CSP that has never blocked anything in its life. The header is there. The scanner may even see a valid policy. And a real injection sails straight through, generating a nice report about the attack that just succeeded.
Cargo-cult headers that browsers already ignore
Then there’s the archaeology. Half the “add these headers” listicles still floating around tell you to set X-XSS-Protection: 1; mode=block. Chrome removed its XSS Auditor — the thing that header controlled — back in Chrome 78, in 2019. The feature is gone. Firefox never implemented it. It did real damage while it existed, too: the auditor could be manipulated into introducing information leaks that wouldn’t otherwise be there, which is exactly why browsers pulled it.
So setting X-XSS-Protection in 2026 does nothing in modern browsers at best, and cargo-cults a header that was actively harmful at worst. But it’s on the checklist. Scanners still look for it. I’ve seen teams add it back because a tool complained it was missing — chasing a grade for a defense that hasn’t existed in years.
X-Frame-Options is a gentler version of the same story. DENY and SAMEORIGIN still work and are still worth setting. But ALLOW-FROM, which people reach for when they want to permit one specific parent origin, is not supported by modern browsers at all — set it and you get no framing protection, not selective framing protection. The real control is CSP’s frame-ancestors, which XFO was supposed to be replaced by years ago. A checklist that says “X-Frame-Options: present” is grading a header that a modern app should mostly have moved past.
The gap the checklist can’t see
Notice the shape of every one of these. HSTS without includeSubDomains protects the apex and leaves every subdomain — including the forgotten staging box — open to SSL stripping. HSTS without preload can’t protect the very first visit, before any header has been seen, which is the exact moment an active attacker wants. Permissions-Policy left unset doesn’t lock features down; it leaves the browser’s permissive defaults in place. In each case the header can be “present” and do a fraction of what its name implies, or nothing at all.
A scanner operates on the response headers. It cannot see your application. It doesn’t know whether your CSP matches how your front end actually loads scripts, whether Report-Only ever got promoted, whether the framing policy reflects a real threat model or a copied snippet. It grades the envelope. Security is in the letter.
I want to be careful here, because the easy misreading is “headers don’t matter, skip them.” That’s not it. Headers matter enormously — a correct CSP is one of the highest-leverage things you can ship, and I’d fight for the effort to deploy one properly. The problem isn’t the headers. It’s the checklist as a stand-in for the work. “Do we set the security headers” is a question a tool can answer. “Does our CSP actually stop an injected script from running in our app” is a question only a person who understands both the policy and the app can answer, and it’s the only question that was ever worth asking.
What passing actually buys you
So here’s where I’ve landed, as someone who builds tools that output exactly these header checks. Treat a green header scan the way you’d treat a passing linter: it means you didn’t make an obvious mechanical mistake. It does not mean the code is correct, and no one sensible thinks a clean lint run means the feature works.
Read your own CSP and trace one path: if an attacker got a <script> onto this page, what stops it? If the answer involves unsafe-inline, the answer is nothing. Check whether your policy is enforcing or reporting. Delete the headers browsers no longer honor instead of re-adding them to satisfy a tool. And when someone closes a ticket called “add security headers,” go read the values — because the difference between a real defense and a decorative one is invisible from the checklist, and it’s the entire difference that matters.
The A+ isn’t the finish line. It’s the point where the actual review starts.