The HTTP Security Headers Guide (with CSP)

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.

The headers that carry their weight #

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.

code

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.

code

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.

code

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.

code

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.

code

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.

Content-Security-Policy, the big one #

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 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 ; 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:

code

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;

Rolling CSP out without an outage #

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.

code

Content-Security-Policy-Report-Only:
default-src 'self';
script-src 'self' 'nonce-{RANDOM}';
object-src 'none';
report-uri /csp-reports;

The process, in order:

Ship the policy in report-only and watch the reports for a week or two across your full traffic mix.

Fix the legitimate violations: add the analytics domain to script-src, replace inline handlers with nonced scripts, move styles out of style attributes.

When reports are down to genuine noise and attack attempts, flip the exact same policy from -Report-Only to Content-Security-Policy.

Keep a reporting endpoint on the enforced policy too, so regressions surface before users file tickets.

Here's the enforced version wired up in Express, generating a nonce per request:

javascript.javascript

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();
});

Testing what you shipped #

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.

The call we'd make #

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.