A practical walkthrough of the HTTP response headers that harden a web app, with a real rollout plan for Content-Security-Policy.
Response headers are the cheapest defense-in-depth you can ship. They don't fix a broken auth flow or a SQL injection, but they shrink the blast radius when something else goes wrong. A handful of headers turn "one bug becomes account takeover" into "one bug the browser refuses to weaponize." This guide walks through each header worth setting, what it actually does, and a value we'd deploy. Then we get into Content-Security-Policy, which deserves its own section because it's powerful, fiddly, and the one most likely to break your site if you rush it.
For the wider context on where headers fit, see our application security best practices guide.
Strict-Transport-Security (HSTS): Forces browsers to use HTTPS for your domain, so a stripped or downgraded connection never gets a chance. Once a browser sees this header, it refuses plain HTTP for the duration of max-age.
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Use a long max-age (two years here). includeSubDomains covers every subdomain, so confirm they all serve HTTPS before enabling it. preload opts you into the browser-baked HSTS list at hstspreload.org, which means the very first request is protected too. Preload is hard to reverse, so treat it as a commitment.
X-Content-Type-Options: nosniff: Stops the browser from guessing a response's content type. Without it, a browser might treat a user-uploaded text file as JavaScript. There is exactly one value and you always want it.
X-Content-Type-Options: nosniff
X-Frame-Options and frame-ancestors: Both defend against clickjacking, where your page gets loaded in an invisible iframe on an attacker's site. X-Frame-Options: DENY is the legacy control; the modern replacement is the CSP frame-ancestors directive, which is more expressive (you can allow specific origins). Set both for coverage across old and new browsers.
X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'
Referrer-Policy: Controls how much of the referring URL leaks to other sites. The default in many browsers still sends the full path to same-origin and downgrades elsewhere, but paths often carry tokens or IDs. strict-origin-when-cross-origin sends the full URL to your own origin and only the bare origin to others, never over a downgrade.
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: Declares which browser features the page and its iframes may use, such as camera, microphone, and geolocation. Deny everything you don't need, so a compromised script can't silently reach for the webcam.
Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=()
A note on deprecated headers: X-XSS-Protection is dead. Modern browsers removed the auditor it controlled because it caused its own information-leak bugs, and in some cases it made XSS worse. Don't set it, or set X-XSS-Protection: 0 to disable any lingering legacy behavior. CSP is the real XSS defense now.
CSP tells the browser exactly where resources may load from and, critically, whether inline scripts are allowed to run. A well-built policy is the single strongest browser control against cross-site scripting, which is why we cover the attack itself in preventing XSS.
The core directives:
default-src is the fallback for any resource type you don't name explicitly.script-src governs JavaScript, and it's where the security actually lives.object-src 'none' and base-uri 'none' close off legacy injection vectors most policies forget.frame-ancestors handles clickjacking as noted above.upgrade-insecure-requests rewrites any http:// subresource to https:// at load time.The hard part is inline scripts. 'unsafe-inline' allows any inline <script> to run, which defeats most of the point, because an injected script is inline too. The two safe alternatives are nonces and hashes. A nonce is a random per-response token you put on both the header and each legitimate <script nonce="...">; the browser runs only scripts carrying the matching value. A hash pins a script by the SHA-256 of its exact contents, which suits static inline blocks that never change. Prefer nonces for server-rendered pages and hashes for fixed snippets. Never ship both 'unsafe-inline' and a nonce expecting the nonce to win in old browsers; it won't.
A reasonable starter policy, generated fresh per request with a nonce:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM}';
style-src 'self';
img-src 'self' data:;
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
upgrade-insecure-requests;
A strict CSP will break things you forgot existed: a third-party analytics tag, an inline onclick handler, a font from a CDN. Deploy it blind and you'll be reverting within the hour. The disciplined path uses report-only mode first.
Content-Security-Policy-Report-Only enforces nothing. The browser evaluates your policy, lets everything load, and posts a JSON violation report to an endpoint you name. You collect real-world violations from real traffic, learn what your policy would have blocked, and tighten it until the reports go quiet.
Content-Security-Policy-Report-Only:
default-src 'self';
script-src 'self' 'nonce-{RANDOM}';
object-src 'none';
report-uri /csp-reports;
The process, in order:
script-src, replace inline handlers with nonced scripts, move styles out of style attributes.-Report-Only to Content-Security-Policy.Here's the enforced version wired up in Express, generating a nonce per request:
const crypto = require('crypto');
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.nonce = nonce;
res.setHeader('Content-Security-Policy', [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}'`,
"style-src 'self'",
"img-src 'self' data:",
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'",
"upgrade-insecure-requests",
].join('; '));
next();
});
Two tools tell you the truth. securityheaders.com grades a live URL and flags missing or weak headers in seconds. Mozilla Observatory goes deeper and scores your CSP quality specifically, calling out 'unsafe-inline' and missing directives. Run both after every change, and check the browser console, which prints the precise directive that blocked each resource.
Set HSTS, nosniff, frame-ancestors, Referrer-Policy, and Permissions-Policy today; they're low-risk and you can ship them in one deploy. Treat CSP as a project, not a header. Start in report-only, drive it with real violation data, and only enforce once the reports are quiet. A nonce-based policy with object-src 'none' and base-uri 'none' is the target. Skip 'unsafe-inline' entirely, retire X-XSS-Protection, and let the browser do the work your application layer can't guarantee.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
A practical guide to hashing passwords, adding MFA, hardening sessions, and avoiding the JWT mistakes that break production auth.
A production-focused Argo CD and GitOps guide: declarative Applications and ApplicationSets, app-of-apps, sync waves and hooks, automated self-heal and prune, progressive delivery with Argo Rollouts, projects/RBAC, and secure secrets — with copy-paste examples.
Explore more articles in this category
Run only the CI jobs a change actually affects using if conditionals, trigger path filters, and per-job path detection in a monorepo.
A prioritized toolkit for cutting CI time: measure the critical path first, then cache, parallelize, run only what changed, and shrink the work itself.
Stop stashing long-lived AWS access keys in GitHub secrets and let OIDC hand your workflows short-lived, scoped credentials instead.
Evergreen posts worth revisiting.