15 min read
A practical, copy-paste-ready breakdown of every HTTP security header that matters — what each one does, what values to use, and what breaks when you get it wrong.
I once spent three days auditing a fintech startup's API before their Series A. Well-written code, solid test coverage, clean architecture. Their engineers were good. Then I ran their main domain through securityheaders.com and got back a solid row of red F's. No Content-Security-Policy. No Strict-Transport-Security. X-Frame-Options missing. Referrer-Policy not set. Their entire frontend was one crafted iframe away from a clickjacking attack, and their users' browsers were volunteering full referrer URLs — including tokens — to every third-party analytics script on the page.
They hadn't made a single mistake in their application code. They'd just never thought about the browser as an attack surface. Almost nobody does, until something goes wrong.
This article is the reference I wish I'd had ten years ago. Not a theoretical overview, but a concrete, copy-paste-ready breakdown of every security header that matters, what it actually does, what values to use in production, and — critically — what breaks when you get it wrong.
The browser is a remarkably permissive execution environment by default. Left to its own defaults, it will load scripts from any origin a page references, render your site inside an iframe on a phishing page, send full URLs in the Referer header to every external resource, and upgrade or not upgrade connections based on whatever the server happens to return. These defaults made sense in 1995. They are a liability in 2024.
Security headers are HTTP response headers your server sends alongside content. They instruct the browser to constrain its own behavior on your behalf. This is fundamentally different from server-side security controls — these headers shift enforcement to the client, specifically the browser, which means they're your last line of defense before the user's session and data hit the wild.
Critically, setting them correctly costs you almost nothing in terms of implementation effort. Getting them wrong — or never setting them at all — costs you exactly the class of attacks they prevent.
Let's go through every one that matters.
This header tells the browser to only ever communicate with your site over HTTPS, for a specified duration. Once a browser sees this header, it will refuse to make plain HTTP connections to your domain — even if the user types http:// — and will instead internally redirect to HTTPS before any network request leaves the machine.
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
max-age is in seconds. 31536000 is one year. includeSubDomains applies the policy to every subdomain — critical, because an attacker who can get a user onto http://staging.yourdomain.com can intercept cookies that aren't scoped with Secure.
preload is a separate mechanism worth understanding. If you submit your domain to the HSTS Preload List — a list maintained by Google and baked into Chrome, Firefox, Safari, and Edge — your domain will be hardcoded into browsers as HTTPS-only before they ever make a first request. This closes the first-visit vulnerability: without preloading, a user's very first visit to your site can still be intercepted via a downgrade attack before HSTS takes effect.
Don't set this header until your entire site runs on HTTPS, including every subdomain you're covering with includeSubDomains. If any subdomain is still on HTTP when you set this, you'll break it for users whose browsers have cached the HSTS policy.
This is the most powerful and most misunderstood security header, and the one most developers avoid because it feels complicated. It is complicated. It's also the primary defense against Cross-Site Scripting (XSS), data injection attacks, and unauthorized resource loading.
CSP defines an allowlist of sources from which the browser is permitted to load different categories of resources: scripts, styles, images, fonts, frames, form targets, and more. A violation causes the browser to block the resource silently (or noisily, if you add report-uri).
A reasonable starting policy for a modern single-page application:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM_NONCE}';
style-src 'self' 'nonce-{RANDOM_NONCE}';
img-src 'self' data: https:;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' https://api.yourdomain.com;
frame-src 'none';
object-src 'none';
base-uri 'self';
form-action 'self';
upgrade-insecure-requests;
A few things worth understanding here.
'self' means the same origin — same scheme, host, and port. It does not include subdomains. If your CDN is assets.yourdomain.com, you'll need to explicitly allow it.
'nonce-{RANDOM_NONCE}' is the modern approach to allowing inline scripts and styles. You generate a cryptographically random value on the server per request, embed it in the CSP header, and add the matching nonce attribute to your legitimate inline <script> and <style> tags. The browser will execute inline scripts only if their nonce matches. An attacker who injects a <script> tag via XSS can't guess the nonce.
<!-- Server generates: nonce = "k3MtiMaH29Lk0sRp" on each request -->
<script nonce="k3MtiMaH29Lk0sRp">
// This executes — nonce matches the CSP header
initApp();
</script>
Avoid 'unsafe-inline' and 'unsafe-eval'. These keywords completely negate the XSS protection that CSP provides. 'unsafe-eval' specifically allows eval(), new Function(), and setTimeout with string arguments — the exact mechanisms XSS payloads use.
object-src 'none' blocks Flash, Java applets, and other plugins. This should always be set. base-uri 'self' prevents base tag injection attacks. form-action 'self' prevents forms from submitting to external attacker-controlled endpoints.
The hard truth about CSP: getting a strict policy working on an existing application is genuinely difficult. Third-party scripts (analytics, chat widgets, A/B testing tools) routinely inject inline scripts and dynamic styles that break CSP. Start with Content-Security-Policy-Report-Only — same syntax, but violations are reported rather than blocked — and use report-uri or report-to to collect violations for a week before enforcing.
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-violations
This header controls whether your page can be embedded in a <frame>, <iframe>, or <object> on another origin. The primary threat is clickjacking: an attacker renders your site transparently over their own, tricking users into clicking elements they think belong to the attacker's UI but actually belong to yours.
X-Frame-Options: DENY
Or, if you need same-origin embedding (e.g., you have a dashboard that iframes its own pages):
X-Frame-Options: SAMEORIGIN
DENY is the stronger choice for any page that handles sensitive actions. Note that X-Frame-Options is technically superseded by the frame-ancestors CSP directive, which is more granular — you can allow specific third-party origins to embed your content while blocking others. But X-Frame-Options has universally consistent browser support and is still worth setting for older clients.
Content-Security-Policy: frame-ancestors 'self' https://trusted-partner.com;
Short, simple, and frequently skipped. This header prevents browsers from doing MIME type sniffing — the behavior where a browser looks at the content of a response and decides it's actually a different type than the Content-Type header declares.
X-Content-Type-Options: nosniff
The attack this prevents: an attacker uploads a file to your server that's labeled as an image (image/jpeg) but contains JavaScript. Without this header, certain browser behaviors or older IE-era MIME sniffing might execute it as a script. With nosniff, the browser trusts only the declared Content-Type and refuses to execute content as a type it wasn't labeled as.
Always set this. There's no valid reason not to.
When a user clicks a link on your site to an external domain, the browser sends a Referer header to that external site with the URL the user came from. On the surface this seems harmless. In practice it leaks session state.
Consider a URL like https://app.yourdomain.com/reset-password?token=abc123. If your reset page includes any external resource — an analytics pixel, a CDN-hosted font, a third-party chat widget — that token gets sent to every one of those services in the Referer header. This is a real class of vulnerability. It's bitten real companies.
Referrer-Policy: strict-origin-when-cross-origin
This value sends the full URL as the referrer for same-origin requests (useful for your own analytics), but only sends the origin (no path, no query string) for cross-origin requests. It's the modern recommended default and strikes the right balance between functionality and privacy.
For high-security contexts — anything involving tokens, IDs, or sensitive query parameters:
Referrer-Policy: no-referrer
This sends nothing. External services see no referrer at all.
This header gives you declarative control over which browser features and APIs your site can use — and, critically, which features scripts running inside your page (including third-party scripts) can use.
Permissions-Policy:
geolocation=(),
microphone=(),
camera=(),
payment=(),
usb=(),
interest-cohort=()
Empty parentheses () means the feature is disabled for all origins, including your own. If you need to allow a feature for your own origin but block it for embedded third-party content:
Permissions-Policy: geolocation=(self), camera=()
interest-cohort=() specifically opts your site out of Google's FLoC (Federated Learning of Cohorts) behavioral tracking system. Even though FLoC was deprecated and replaced by the Topics API, setting this remains a meaningful privacy statement and still affects Topics API eligibility in some browser implementations.
The practical value here: if a third-party script you've loaded gets compromised, this header prevents the attacker from silently activating the user's microphone or camera from within your origin. It's defense in depth for your supply chain.
This header controls how your page interacts with cross-origin windows it opens, or that open it. It's one of the newer headers (broadly supported since 2021) and its primary purpose is isolation from Spectre-class side-channel attacks.
Cross-Origin-Opener-Policy: same-origin
same-origin prevents other origins from getting a reference to your window object (via window.opener) and prevents your origin from getting references to cross-origin windows. This matters because window.opener access can be used to steal data via timing attacks in some configurations.
For pages that need to receive postMessage from trusted cross-origin partners (OAuth callbacks, payment gateway flows), use:
Cross-Origin-Opener-Policy: same-origin-allow-popups
Paired with COOP, COEP restricts which cross-origin resources can be loaded into your page without explicit permission. Setting both COOP: same-origin and COEP: require-corp enables SharedArrayBuffer and high-resolution timers — required for certain performance-sensitive applications like WebAssembly-heavy workloads — while isolating your page from cross-origin interference.
Cross-Origin-Embedder-Policy: require-corp
Be careful here. Setting COEP: require-corp means every cross-origin resource you load — images, scripts, fonts — must either be same-origin or explicitly opt in via the Cross-Origin-Resource-Policy header. If you're loading anything from a CDN or third-party service that doesn't send CORP, it will be blocked. Don't set this header on a content-heavy site without testing thoroughly.
The counterpart to COEP. This header goes on your static assets and API responses, declaring which origins are allowed to load them.
Cross-Origin-Resource-Policy: same-origin
For a public API or CDN asset that's intentionally accessible from other origins:
Cross-Origin-Resource-Policy: cross-origin
For resources that should only be loaded by your own site:
Cross-Origin-Resource-Policy: same-site
same-site is broader than same-origin — it includes subdomains of the same registrable domain. Use same-origin when you want strict isolation.
Not a security header in the traditional sense, but a critical hygiene measure that belongs in every checklist. Any response that contains sensitive user data — authenticated API responses, pages behind login, tokens in the body — should include:
Cache-Control: no-store
Pragma: no-cache
no-store tells every cache in the chain — the browser cache, any proxy, any CDN — not to store this response at all. Pragma: no-cache is an HTTP/1.0 backward-compatibility measure; include it for safety.
If you deploy your application behind a shared reverse proxy or CDN and forget this header on an authenticated endpoint, you risk serving one user's response to another user. This happens in production at scale more often than you'd want to know.
Here's a production-ready header set for a modern web application, expressed as Nginx config:
# Nginx: Add to your server{} block or location block
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" 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=(), microphone=(), camera=(), payment=(), usb=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
# CSP needs to be customized per application — this is a starting point only
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests" always;
For Express.js, use Helmet (currently v7.x, ~3.5k GitHub stars as of mid-2024), which handles all of this with sane defaults and a clean API:
import helmet from "helmet";
import express from "express";
const app = express();
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
imgSrc: ["'self'", "data:", "https:"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
upgradeInsecureRequests: [],
},
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
frameguard: { action: "deny" },
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
})
);
// Generate a nonce per request
app.use((req, res, next) => {
res.locals.cspNonce = crypto.randomBytes(16).toString("base64");
next();
});
For Next.js 14+, headers can be configured in next.config.js and middleware:
// next.config.js
const securityHeaders = [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{
key: "Strict-Transport-Security",
value: "max-age=31536000; includeSubDomains; preload",
},
{
key: "Permissions-Policy",
value: "geolocation=(), microphone=(), camera=()",
},
];
module.exports = {
async headers() {
return [
{
source: "/(.*)",
headers: securityHeaders,
},
];
},
};
Don't deploy and trust. Verify.
securityheaders.com is the quickest free scanner — paste your URL and get a graded report in seconds. Mozilla Observatory is more thorough, covers TLS configuration alongside headers, and is what I recommend for a real security review. Both are free.
For CI/CD integration, consider shcheck — a Python CLI that checks a URL's headers and can exit non-zero on failures, making it suitable for a pipeline gate:
pip install shcheck
shcheck https://yourdomain.com --fatal-on-missing
For automated Lighthouse audits in your pipeline (which includes header checks), the Lighthouse CI GitHub Action gives you header regressions in pull requests.
A few things I want to be direct about.
Security headers are not a substitute for application security. They're a defense-in-depth layer. If you have SQL injection vulnerabilities or broken authentication, setting X-Frame-Options to DENY does nothing for you. Fix the fundamentals first; headers are the outer layer of the onion, not the center.
CSP is genuinely hard to get right on large applications, and a poorly configured CSP can break legitimate functionality in ways that are subtle and hard to debug. I've seen teams give up on CSP entirely after a bad rollout experience. The Report-Only mode exists precisely for this reason — use it, and give yourself weeks of data before switching to enforcement mode.
Browser support for the newer headers (COOP, COEP, CORP) is good on modern browsers as of 2024, but if you have users on Internet Explorer 11 (I hope you don't) or very old mobile browsers, these headers will simply be ignored. They fail safely — ignored headers don't break anything — but you shouldn't count on them for those users.
Finally: headers sent by the server are visible to anyone who looks at the response. Setting a strong Content-Security-Policy header is not a secret. Attackers can see exactly what your policy allows and craft payloads accordingly. This is fine — security by obscurity was never the goal — but don't mistake a properly configured policy for invisibility.
Security headers represent a broader shift in web security toward declarative constraints. Rather than trying to sanitize every input and anticipate every attack pattern — an endless arms race — you're telling the browser: here is the minimum capability required for my application to function, block everything else. This is the principle of least privilege applied at the protocol layer.
The web platform has gotten significantly more serious about this over the last decade. The introduction of COOP, COEP, and the ongoing development of Fetch Metadata request headers represent a real architectural push toward making browsers active participants in defense rather than passive delivery mechanisms. Following this direction, not fighting it, is how you build applications that age well.
Fifteen minutes and the right configuration file is all it takes to get from zero headers to a solid security posture. There's almost no other security investment with that ratio of effort to impact.
If you're a backend or full-stack engineer who ships web applications and hasn't explicitly set security headers — this is your to-do item for this week, not next quarter. If you're a security engineer or team lead doing an application review, this list is a baseline checklist for any web property before it gets to production. If you're a frontend engineer who thinks security is "the backend's problem," the CSP and Permissions-Policy sections were written specifically for you.
The browser is your user's environment. Take some responsibility for what you ask it to do.
Strict-Transport-Security: max-age=31536000; includeSubDomains; preloadContent-Security-Policy — start with Report-Only, collect violations, then enforceX-Frame-Options: DENY (or use CSP frame-ancestors)X-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originPermissions-Policy — disable geolocation, microphone, camera, payment at minimumCross-Origin-Opener-Policy: same-originCache-Control: no-store on all authenticated/sensitive responses