Most cross-site scripting attacks, clickjacking attempts, and protocol-downgrade exploits succeed not because the bug they're abusing is exotic, but because the response from the vulnerable site didn't tell the browser to refuse. The browser doesn't ship with paranoid defaults — by design, it does what the server tells it. If the server doesn't say "don't execute inline scripts," the browser executes inline scripts. If the server doesn't say "force HTTPS," the browser will follow a plain-HTTP link without complaint.
HTTP security headers are how a server tells the browser to be paranoid on its behalf. They're cheap — most are a single line in your CDN or web server config — and they're the lowest-effort, highest-ROI hardening you can apply to a public-facing site. This post walks through the six headers that account for nearly all of the practical security signal, what each one prevents, and where they break.
What security headers are, exactly
An HTTP response carries a body (the HTML, JSON, image, whatever) and a stack of headers. Security headers are just response headers whose values tell the browser to enforce a specific protection: only load scripts from these origins, never connect over plain HTTP, don't let other sites embed you in an iframe. The browser sees the header and adjusts its behaviour for that response and, in some cases, for the entire origin going forward.
They're defense in depth, not a substitute for fixing server-side bugs. A site with a SQL injection vulnerability will still be exploitable with perfect security headers — the attacker just won't be able to chain in some of the worst client-side payloads. But the difference between "an XSS bug exists and the attacker can do anything they want in the user's browser" and "an XSS bug exists but CSP blocks the injected script from running" is the difference between a critical incident and a non-event.
The six that matter most
1. Content-Security-Policy
The most powerful of the six, and the one that takes the most care to get right. CSP lets you whitelist exactly which sources the browser is allowed to load scripts, styles, images, fonts, and other resources from. An injected <script> tag pointing at an attacker's server simply doesn't run, because the attacker's server isn't on your allowlist.
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com
That policy says "by default, only same-origin resources, but additionally allow scripts from cdn.example.com." A determined attacker would have to find a way to inject content into your origin or the CDN, not just any URL.
The traps. 'unsafe-inline' allows inline <script> blocks and inline event handlers — easy to add when you're starting, devastating to CSP's protection value. 'unsafe-eval' allows eval() and friends — most modern frameworks don't need it. Wildcards like script-src * defeat the entire point. The right way to handle the "but I have inline scripts" case is nonces: emit a fresh random string in the CSP header, include it on every inline script tag you control, and only those legitimate scripts execute. Hashes work similarly but require recalculating when content changes.
CSP is the hardest header to get right, particularly on sites that pull in third-party analytics, tag managers, A/B testing tools, and embedded widgets. Every vendor adds another source you need to whitelist, and they tend to move endpoints around without warning. Start with Content-Security-Policy-Report-Only mode, watch the browser report what would have been blocked, and tighten over weeks.
2. Strict-Transport-Security
HSTS tells the browser "for the next N seconds, never contact this domain over plain HTTP — automatically upgrade any HTTP URL to HTTPS before connecting." That defeats SSL stripping (where an attacker on the network intercepts an HTTP request and serves their own page while making the user think they're talking to the real site), accidental HTTP links that would otherwise expose cookies or tokens in transit, and protocol downgrade attacks generally.
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
max-age=31536000— remember this for one year. Browsers cache the policy and enforce it for the full duration even if you stop sending the header.includeSubDomains— apply the same policy to every subdomain. Most sites want this, but be careful if any subdomain still legitimately serves plain HTTP.preload— declare your intent to be on the HSTS preload list, a hardcoded list shipped inside Chrome, Firefox, Safari, and Edge. Once your domain is preloaded, browsers force HTTPS even on the first visit, before they've ever seen your header.
Start with a short max-age like 300 (five minutes) to verify nothing breaks, ramp to one or two years once confident. Submitting to the preload list at hstspreload.org is one-way at human time scales — removal can take months — so make sure you're ready before opting in.
3. X-Content-Type-Options
The simplest header on the list and the one with the smallest footprint:
X-Content-Type-Options: nosniff
One value, one purpose. It tells the browser to trust the Content-Type you sent and not "helpfully" infer a different one from the bytes. Without it, a file labelled text/plain that happens to start with <script> tags might be rendered as HTML — turning a benign user upload into an XSS surface. With nosniff, the file is what the server said it was. Set it; nothing legitimate breaks.
4. X-Frame-Options (or CSP frame-ancestors)
Clickjacking is the attack where an attacker overlays your site inside an iframe on their site, makes the iframe transparent, and tricks the user into clicking buttons on your page (delete account, transfer funds, accept terms) while thinking they're clicking something innocent on the attacker's site. X-Frame-Options tells the browser whether to allow your page inside an iframe at all:
X-Frame-Options: DENY
Or, more permissively:
X-Frame-Options: SAMEORIGIN
Modern best practice is to use CSP's frame-ancestors directive instead, which can express more nuanced policies (allow a specific partner site, for example) and supersedes X-Frame-Options when both are present:
Content-Security-Policy: frame-ancestors 'none'
Setting both is harmless and useful for backward compatibility with older browsers. If your site doesn't legitimately get embedded anywhere, DENY / 'none' is the right answer.
5. Referrer-Policy
When a user clicks a link from your site to another, the browser sends a Referer header — historically misspelled, by accident, in 1995 — telling the destination where the click came from. By default, that header carries the full URL, including any query string. If your URLs contain session tokens, password-reset links, internal paths, or anything else you'd rather keep private, that data leaks to every site users navigate to.
Referrer-Policy: strict-origin-when-cross-origin
That value (which is also the default in modern browsers, but explicitly setting it is good practice) sends the full URL only on same-origin links, just the origin (no path or query) on cross-origin HTTPS, and nothing at all on HTTPS-to-HTTP downgrades. Most sites should set exactly this. no-referrer sends nothing ever — strictest but breaks some analytics. unsafe-url sends everything always — actively bad; never use it.
6. Permissions-Policy
Formerly called Feature-Policy. Controls which browser features your page (and any iframes it loads) can use: camera, microphone, geolocation, payment APIs, autoplay, accelerometer, and a couple dozen others. Default behaviour without this header is "any script can request access." With it, you declare a strict allowlist:
Permissions-Policy: camera=(), microphone=(), geolocation=(self), payment=()
camera=()— empty parentheses mean "blocked for everyone, including same-origin." Use this for features your site doesn't legitimately use.geolocation=(self)— allowed only for the current origin. An iframe from another site couldn't request geolocation.autoplay=*— allowed everywhere, including cross-origin iframes. Use sparingly.
A site that never needs the camera should explicitly say so. The header prevents an XSS payload, a compromised dependency, or a malicious third-party iframe from popping a camera permission prompt.
The grading system
The security headers grader assigns 100 points across the six headers above and maps the total to a letter from A+ down to F. The breakdown:
- HSTS — 20 points (full credit at
max-ageof one year or more), with a 5-point bonus for theincludeSubDomains; preloadcombination that qualifies you for the preload list. - CSP — 20 points, minus deductions for
'unsafe-inline','unsafe-eval', and wildcards inscript-srcordefault-src. - X-Content-Type-Options — 15 points.
- X-Frame-Options or
frame-ancestors— 15 points (either qualifies). - Referrer-Policy — 15 points unless set to
unsafe-url. - Permissions-Policy — 15 points if present.
A+ requires 95+ (perfect plus the HSTS preload bonus). A spans 85-94, B is 70-84, C is 55-69, D is 40-54, F is anything below. Most major sites don't sit at A+ — legacy inline scripts, third-party tag managers, and grandfathered CSP exceptions are everywhere — but most can comfortably hit A or B with a few hours of work.
How to actually set them
The mechanics depend on your stack but all look broadly similar:
- Cloudflare. A
_headersfile in your project root (Pages and Workers), or Transform Rules in the dashboard. The_headersapproach versions your config alongside your code, which is the cleanest workflow. - Nginx.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;inside the relevantserverblock. - Apache.
Header set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"in.htaccessor your virtual-host config. - CDNs and platforms. Vercel, Netlify, Fastly, AWS CloudFront — every modern host has a "custom response headers" UI somewhere. The header values are the same regardless of how you configure them.
Header changes from your origin apply instantly, but CDN edges may serve cached responses for a few minutes until they revalidate — relevant if you're testing right after deploy. (See DNS propagation for the broader picture.) The security headers grader bypasses local caches and fetches live, so it's the right way to confirm the change shipped.
What about X-XSS-Protection?
If you've read older guides, you've seen X-XSS-Protection recommended. Don't set it. It originally enabled a built-in XSS filter in early Chrome and IE, but Chrome removed the filter in 2019 and Firefox never implemented it. Some past configurations of the header actively introduced vulnerabilities. CSP is the proper replacement.
Set the six. Skip X-XSS-Protection. Run the grader. Fix what isn't green.
None of this prevents server-side bugs — but it makes the bugs you do have far less exploitable, raises the bar for opportunistic scanners, and demonstrates basic care to anyone evaluating your site's security posture. The whole exercise takes a few hours the first time and almost no maintenance afterwards. Combined with a current TLS configuration (see our SSL inspector for a quick check), it's the cheapest dollars-per-defence you'll spend on the web.
Check your security headers
Paste any URL to get an A+ to F grade with full CSP directive analysis, HSTS preload eligibility, and per-header point breakdown — including the specific values that cost you points.
Check your security headers →