Security headers are at once the cheapest line of defense on the web and the most ignored. They aren’t code, they aren’t a new library, they don’t require refactoring the application: they’re instructions the server sends along with the HTTP response telling the browser “treat this site with these security rules.” One line of configuration and the browser starts refusing to be placed inside an iframe, accepting only HTTPS, or blocking an injected script. It’s defense that happens on the client, for free, without you touching the backend.
And even though it’s this cheap, most sites get a low grade. In day-to-day audits, it’s common to find applications in production, handling sensitive data, that don’t send a single security header. The result is that attacks that should be impossible or expensive become trivial: clickjacking, HTTPS downgrade, session theft from an XSS that, with the right configuration, would never have gotten off the ground.
To make diagnosis easier, IntruderLabs released a free tool that pastes in your site’s headers, gives you a grade from A+ to F, and generates the configuration snippet ready for your stack: the Security Headers Analyzer. Use it while you read this article. Below: what each header actually protects, where the gotchas are, and how the absence of each one turns into exploitation in a real pentest.
What each header actually protects
Content-Security-Policy (CSP)
CSP is the strongest defense there is against XSS in the browser. It tells the browser where scripts, styles, images, and frames can come from — and blocks the rest. A good starting point:
Content-Security-Policy: default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
This already shuts down plugins (object-src 'none'), prevents base-URL hijacking (base-uri 'self'), and blocks the site from being embedded in iframes (frame-ancestors 'none').
The catch: CSP is only worth something if you don’t poke holes in your own policy. The moment you add script-src 'unsafe-inline' — or worse, script-src * — to “make the site work,” the XSS protection evaporates, because inline scripts and scripts from arbitrary origins are exactly what the attacker wants to execute. The right way is to use a nonce (script-src 'nonce-aBc123') or a hash of the trusted scripts. Just one caveat: the nonce needs to be generated per request, random, and unpredictable (around 128 bits in base64) — a fixed value in your code protects nothing. It’s more work, but it’s the difference between having CSP and having a decorative header. And remember: CSP mitigates XSS, it doesn’t fix the underlying flaw — sanitization and output encoding are still mandatory.
Strict-Transport-Security (HSTS)
HSTS forces the browser to speak only HTTPS with your domain, shutting down SSL-strip and downgrade attacks where someone on the network pushes the victim back to cleartext HTTP.
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
max-age in seconds (here, two years), includeSubDomains to cover all subdomains, and preload to get onto the browsers’ built-in list.
The catch: a short max-age (like a few hours) leaves downgrade windows wide open. And the point almost everyone gets wrong: HSTS doesn’t protect the very first visit. The browser only starts forcing HTTPS after it sees the header once. That first request is still vulnerable to downgrade — the only way to close that window is to be on the HSTS preload list, which ships compiled into the browser. That’s why the preload directive and manually registering on the list’s site matter: sending the directive is just the prerequisite; the entry only counts after it’s submitted and approved (and getting off the list later is slow). Without it, every user’s first visit is exposed.
X-Frame-Options / CSP frame-ancestors
These are the anti-clickjacking defense — they prevent your site from being loaded inside an iframe on a malicious domain that overlays the interface to steal clicks.
X-Frame-Options: DENY
The modern, more expressive version is the frame-ancestors 'none' directive inside the CSP, which plays the same role and also lets you allowlist origins when you need to (frame-ancestors 'self' https://partner.com). In current browsers, when both are present, frame-ancestors takes precedence over X-Frame-Options; the ideal is to send both to cover older clients. One important note: the old ALLOW-FROM value of X-Frame-Options is dead — it was never properly supported and has been removed from browsers. If you need to allow a specific origin, use frame-ancestors. We go into detail in the article on clickjacking.
X-Content-Type-Options: nosniff
X-Content-Type-Options: nosniff
This turns off MIME-sniffing: the browser behavior of “guessing” a file’s type while ignoring the declared Content-Type. Without nosniff, a file you served as text can be reinterpreted as JavaScript and executed — the classic case is a user upload, served with a generic or wrong Content-Type, that the browser “sniffs” as HTML and runs. nosniff is, by the way, the only valid value for this header.
Referrer-Policy
Referrer-Policy: strict-origin-when-cross-origin
Controls how much of the originating URL goes in the Referer header when the user leaves your site. The recommended value sends the full URL within the same origin and only the origin (no path, no query) when navigating away. The catch is unsafe-url, which sends the entire URL to any destination — and if you have tokens, session IDs, or password-reset IDs in the query string, they leak to third-party sites and end up in their logs.
Permissions-Policy
Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=(), usb=()
Turns off powerful browser APIs your application doesn’t use. If the site doesn’t need camera, microphone, or geolocation, deny everything: that way, even if a third-party script (or an XSS) tries to access them, the browser refuses. Each feature=() pair with an empty list means “no one can use this, not even me.”
COOP / CORP / COEP
A cross-origin isolation trio, to shut down side-channel attacks (like Spectre) and leaks between windows:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
COOP separates your window context from other origins; CORP controls who can embed your resources. COEP (require-corp) is optional and trickier: together with COOP it enables cross-origin isolation (required for SharedArrayBuffer, memory measurement, and high-resolution timers), but it demands that every cross-origin resource come with the right header, which usually breaks third-party embeds, widgets, and images. Only turn on COEP if you need those features and you’re willing to audit every external origin.
What you should REMOVE
Not everything is about adding headers — some need to go. The rule is: don’t hand the attacker, for free, information that helps them pick the exploit.
Server: Apache/2.4.58— the exact server version is a shortcut to looking up CVEs specific to that build.X-Powered-By: PHP/8.1.2— gives away the language and version.X-AspNet-VersionandX-AspNetMvc-Version— same thing for the .NET stack.
None of these headers do anything for your application; they just save the attacker the work of reconnaissance. Strip the version (or the entire header) in the server configuration. This isn’t security through obscurity replacing real defense — it’s just not leaving the treasure map on the doorstep.
Cookies are headers too
Set-Cookie is an HTTP header, and the right attributes on it are part of your security perimeter:
Set-Cookie: sessionid=abc123; Secure; HttpOnly; SameSite=Lax
Secure— the cookie is only sent over HTTPS, never in cleartext.HttpOnly— the cookie is invisible to JavaScript. This one is decisive: withoutHttpOnly, an XSS readsdocument.cookieand exfiltrates the entire session. WithHttpOnly, even a successful XSS can’t steal the session cookie directly.SameSite=Lax— the browser won’t send the cookie in third-party cross-site requests, which is an important anti-CSRF foundation. If you really do needSameSite=None(legitimate cross-site scenarios), know that it requires theSecureattribute — without it, the browser rejects the cookie. We break the attack down in the article on CSRF; just remember thatSameSitemitigates, it doesn’t replace anti-CSRF tokens.
How the absence turns into exploitation in a pentest
Missing headers are rarely the “big finding” on their own. What they do is unlock or amplify other attacks. In practice, here’s how the absence of each one shows up in a test:
No X-Frame-Options and no frame-ancestors: we build a proof-of-concept page with your site inside a transparent <iframe> over fake buttons. The user thinks they’re clicking our bait and is actually clicking “Confirm transfer” or “Authorize app” on your logged-in site. Functional clickjacking, with an HTML PoC that fits in an email.
No nosniff: we look for any spot that reflects or stores user-controlled content — a file upload, a field that echoes text back. By serving a payload with an ambiguous Content-Type, the browser sniffs it and executes it as a script. An upload that should have been inert becomes XSS.
No HSTS (or no preload): in a hostile-network scenario (public Wi-Fi, attacker on the same LAN), we intercept the first HTTP request and do SSL-strip, keeping the victim on cleartext HTTP and capturing credentials. Even with HSTS active, if the domain isn’t on the preload list, that first request is still the gap.
No proper Referrer-Policy: we hunt for URLs with tokens in the query string (password resets, invites, signed links). If the site loads an external resource or link from those pages, the token leaks in the Referer to the third party — and stays in their logs. Token hijacking without even touching the target server.
With weak (or absent) CSP: when we find an XSS, the CSP is what decides whether it’s Low or critical. Without CSP, the payload runs free — exfiltrates data, pivots, installs a keylogger. With a strong CSP and a nonce, the same payload is blocked before it executes. With a CSP that’s “present but full of holes” ('unsafe-inline'), we treat it as if there were none: the injection goes through all the same.
No HttpOnly on the session cookie: combined with any XSS, we read document.cookie and send the session to a server of ours. A direct escalation from “reflected XSS” to “account takeover,” with nothing else needed.
Notice the pattern: almost every isolated header finding is Low or Info, but each one removes a layer that makes another attack cheaper. That’s why they show up in the report.
Summary for the report
Finding: Missing HTTP security headers
Impact: The application doesn’t send security headers that instruct the browser to apply protections against clickjacking, HTTPS downgrade, MIME-sniffing, and information leakage via Referer. On their own they don’t compromise the application, but they lower the defense-in-depth barriers and amplify the impact of other flaws (XSS, CSRF, session hijacking).
Severity: Low (enabler — raises the severity of correlated flaws)
CVSS: 4.3 (AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N) — CVSS 3.1, Low
Preconditions: Network access to the application; for some of the vectors (clickjacking, SSL-strip, session theft), user interaction and/or a privileged network position.
Evidence: Server HTTP response missing the
Content-Security-Policy,Strict-Transport-Security,X-Frame-Options,X-Content-Type-Options,Referrer-Policy, andPermissions-Policyheaders (captured viacurl -I https://target). Session cookies issued withoutHttpOnly/Secure.
Adjust the CVSS vector based on what’s actually missing and the target’s context — this is a reasonable reference value for “missing security headers” on an internet-exposed application.
How to configure it (the exact line)
Nginx
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(), usb=()" always;
The always ensures the header also goes out on error responses (4xx/5xx). To remove the server version, use server_tokens off;.
Apache
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
Header always set Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
Header always set X-Frame-Options "DENY"
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(), usb=()"
Header always unset X-Powered-By
Requires the mod_headers module. To hide the version, ServerTokens Prod and ServerSignature Off.
Node / Express (helmet)
The shortcut here is helmet, which already turns on most of these headers with sane defaults in one line:
const helmet = require("helmet");
app.use(helmet({
strictTransportSecurity: {
maxAge: 63072000,
includeSubDomains: true,
preload: true,
},
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
},
},
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
}));
app.disable("x-powered-by");
helmet already sends X-Frame-Options: DENY, X-Content-Type-Options: nosniff, and friends by default; you only override what you need. Note that it does not set Permissions-Policy by default — add it by hand if you want it.
Cloudflare
If you’re behind Cloudflare, you don’t need to touch the origin server. Use the Managed Transforms → Add security headers (ready-made toggles in the dashboard, including HSTS) or, for fine-grained control, the Transform Rules → HTTP Response Header Modification, adding and removing each header. It’s the fastest way to apply this across an entire fleet of domains without a deploy.
Worth a reminder: you don’t have to assemble these blocks by hand. Paste your site’s headers into the Security Headers Analyzer and it generates the fix automatically for Nginx, Apache, helmet, and Cloudflare, already with the recommended values.
Security headers checklist
- Strong CSP with
default-src 'self',object-src 'none',base-uri 'self', and nonce/hash instead of'unsafe-inline' - HSTS with a long
max-age,includeSubDomains,preload— and the domain registered on the preload list - X-Frame-Options: DENY +
frame-ancestors 'none'in the CSP - X-Content-Type-Options: nosniff
- Referrer-Policy: strict-origin-when-cross-origin (never
unsafe-url) - Permissions-Policy denying camera, microphone, geolocation, and anything the app doesn’t use
- COOP (
same-origin) and CORP; COEP only if truly necessary - Remove
Serverwith a version,X-Powered-By,X-AspNet-Version - Cookies with
Secure,HttpOnly, andSameSite(Laxby default;Noneonly withSecure) - Re-validate with a scoring tool and make sure the headers also go out on error responses
Closing
Security headers are the most obvious quick win there is: minutes of configuration that raise the bar against a whole range of attacks and reduce the damage from others. But make no mistake — they’re defense in depth, not a substitute for actually fixing the flaws. A flawless CSP doesn’t fix the XSS underneath it, it only contains it. If you want to know what an attacker would actually do with your application — with or without the headers in place — talk to us. We test for real.