HTTP Security Headers: A Practical Hardening Guide for CSP, HSTS, and Cross-Origin Isolation
HTTP Header Security Auditor
Paste a raw response, get an A+ to F hardening grade with per-header findings, and export a matching Nginx or Apache configuration snippet.
Headers Are the Cheapest Security You Will Ever Deploy
Most web hardening work requires code changes, reviews, and regression testing. Security headers require a configuration line at the edge and cost nothing at runtime.
They are also the layer most often left at framework defaults — which is why a first audit of a mature application so often returns a grade of D or F.
The table below is the shortlist. Everything after it is detail.
| Header | Blocks | Priority |
|---|---|---|
Content-Security-Policy | Cross-site scripting, data injection | Critical |
Strict-Transport-Security | Protocol downgrade, SSL stripping | Critical |
X-Content-Type-Options | MIME-sniffing confusion attacks | Critical |
frame-ancestors / X-Frame-Options | Clickjacking, UI redress | High |
Referrer-Policy | URL and token leakage via Referer | High |
Permissions-Policy | Unwanted camera, mic, geolocation access | Medium |
COOP / COEP / CORP | Cross-origin leaks; enables isolation | Situational |
Cache-Control: no-store | Sensitive data cached on shared devices | Situational |
Content-Security-Policy: The One That Does the Work
CSP tells the browser which sources of script, style, and other content are legitimate. Done properly, it turns a cross-site scripting bug from a full account takeover into a blocked console error.
The Modern Strict Policy
Content-Security-Policy:
default-src 'self';
script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
require-trusted-types-for 'script';
Each line earns its place:
'nonce-{RANDOM}'— a fresh, cryptographically random value of at least 128 bits, generated per response and echoed on every legitimate<script nonce="...">. A reused or predictable nonce is no protection at all.'strict-dynamic'— lets a nonce-approved script load further scripts it creates programmatically, and causes host allowlists to be ignored. This matters because allowlist-based policies are routinely bypassable through JSONP endpoints and open redirects on allowlisted CDNs.https: 'unsafe-inline'— a deliberate fallback for browsers too old to understand nonces. Modern browsers ignore'unsafe-inline'whenever a nonce or hash is present, which is specified behaviour, so this line is backward compatibility rather than a hole.object-src 'none'— removes the<object>,<embed>, and legacy plugin bypass surface. There is almost never a reason to allow it.base-uri 'self'— prevents an injected<base>tag from redirecting every relative URL on the page to an attacker’s host.frame-ancestors 'none'— the modern clickjacking control, supersedingX-Frame-Options.
Roll It Out in Report-Only Mode First
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report;
The Report-Only variant logs violations without blocking anything. Run it for a full traffic cycle — including whatever nightly and monthly jobs render pages — before switching to the enforcing header. Deploying an untested CSP straight to enforcement is the fastest way to take your own site down.
Directives That Do Not Behave as Expected
default-srcis not a universal fallback. It does not coverbase-uri,frame-ancestors,form-action, orsandbox. Set those explicitly.'unsafe-eval're-enablesevaland the string form ofsetTimeout. Some older bundlers and templating libraries need it; treat needing it as technical debt.- A
meta http-equivCSP silently dropsframe-ancestors,report-uri, andsandbox, and only applies from the point the parser reads the tag. Send CSP as a real header.
Strict-Transport-Security
HSTS (RFC 6797) instructs the browser to refuse plaintext HTTP for a host for a fixed period — closing the window in which a first plaintext request can be intercepted and stripped.
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
| Token | Meaning | Notes |
|---|---|---|
max-age | Seconds to remember the HTTPS-only rule | Required. 63072000 = two years |
includeSubDomains | Applies to every subdomain | Verify every subdomain serves HTTPS first |
preload | Requests inclusion in browser preload lists | Not part of RFC 6797; see hstspreload.org |
Preload eligibility, per hstspreload.org, requires a valid certificate, an HTTP-to-HTTPS redirect on the same host, and a header on the HTTPS root with max-age of at least 31536000 plus both includeSubDomains and preload.
Two operational warnings:
- Preloading is hard to undo. Removal requests take effect only as browsers ship new versions, which can mean months. A subdomain that cannot serve HTTPS becomes unreachable for users whose browsers hold the preload entry.
- Ramp
max-ageupward. Start at300, then a day, then a week, then a year. A mistake atmax-age=63072000is a two-year commitment for every visitor who received it.
HSTS is only sent over HTTPS. Browsers ignore it on a plaintext response, by design.
Clickjacking, Sniffing, and Referrer Leakage
X-Content-Type-Options: nosniff
One value, no options, no downside. It stops the browser from second-guessing your Content-Type, which is what turns an uploaded .txt file containing markup into stored XSS. Send it on every response.
X-Frame-Options
X-Frame-Options: DENY # or SAMEORIGIN
Defined in RFC 7034 and superseded by CSP frame-ancestors. Note that the ALLOW-FROM value was never implemented by Chrome or Safari — to permit a specific external framer, you must use frame-ancestors https://partner.example. Sending both headers is common and harmless, as long as they do not contradict each other.
Referrer-Policy
Referrer-Policy: strict-origin-when-cross-origin
This is now the browser default in Chrome and Firefox, but sending it explicitly documents the intent and protects against a proxy or framework overriding it. Cross-origin requests then carry only the origin — no path, no query string, no accidental token disclosure. Use no-referrer for pages whose URLs are themselves sensitive, such as password-reset links.
Permissions-Policy
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
An empty allowlist () denies the feature to the document and every frame it embeds. Deny what you do not use — a third-party script in an iframe cannot prompt for a camera it has no permission to request. This header replaces the older Feature-Policy, which used a different value syntax.
Cross-Origin Isolation: COOP, COEP, CORP
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: same-origin
- COOP
same-originsevers thewindow.openerrelationship with cross-origin pages, closing a family of tab-nabbing and cross-window attacks. - COEP
require-corprequires every cross-origin subresource to opt in explicitly, via CORS orCross-Origin-Resource-Policy. - CORP is the opt-in mechanism resources use to declare who may embed them.
COOP plus COEP together put the document in a cross-origin isolated state, which is a prerequisite for SharedArrayBuffer and high-resolution timers. That makes them mandatory for WebAssembly-heavy applications — and also the pair most likely to break third-party embeds. COOP alone is a safe, valuable default; adopt COEP only when you need isolation and have audited every embedded resource.
Headers to Delete
Hardening is subtraction as well as addition.
| Header | Action | Reason |
|---|---|---|
X-XSS-Protection | Remove | The Chrome XSS Auditor it controlled was removed; the filter itself introduced information-leak bugs. OWASP advises against it. |
X-Powered-By | Remove | Discloses runtime and version, handing an attacker a CVE shortlist |
Server | Minimise | Apache/2.4.41 (Ubuntu) names the exact patch level to target |
X-AspNet-Version | Remove | Same disclosure problem |
Public-Key-Pins | Remove | HPKP is obsolete and removed from browsers; use Certificate Transparency and CAA records |
Also remember Cache-Control: no-store on any authenticated response. A shared or kiosk browser retaining an account page in its back-forward cache is a real disclosure, and no-cache alone does not prevent storage.
Server Configuration Pitfalls
Nginx: inheritance and error responses
add_header Content-Security-Policy "default-src 'self'" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
Two behaviours cause almost every “header missing on some pages” report:
add_headerdirectives are not merged across levels. A singleadd_headerinside alocationblock discards all inheritedadd_headerdirectives fromserverandhttp. Repeat the full set in that block.- Without
always, headers are only added to successful responses. Your 404 and 500 pages ship unprotected — and those are exactly the pages most likely to reflect user input.
Apache
Header always set Content-Security-Policy "default-src 'self'"
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains"
Header always set X-Content-Type-Options "nosniff"
Header always unset X-Powered-By
Header always set is the equivalent of Nginx’s always, covering error responses too.
A Note on CORS
Access-Control-Allow-Origin is not a hardening header — it relaxes the same-origin policy. And Access-Control-Allow-Origin: * cannot be combined with Access-Control-Allow-Credentials: true; browsers reject that pairing. Reflecting an arbitrary Origin header back while allowing credentials is a serious vulnerability, not a configuration convenience.
Step-by-Step: Auditing with Toolbox
- Capture your real headers. Run
curl -sSI https://yoursite.exampleor copy the response headers from your browser’s Network panel. - Open the tool: visit the Toolbox HTTP Header Security Auditor.
- Paste the raw response into the input box, or start from one of the built-in presets to see what a hardened and an unhardened response look like side by side.
- Read the Audit tab for the letter grade from A+ to F and the per-header findings behind it.
- Check the Table tab for the parsed name-value view, which is the quickest way to spot a duplicated or malformed header.
- Export from the Config tab — select Nginx or Apache and copy the generated hardening snippet straight into your server or edge configuration.
- Re-audit after deploying, and check a 404 as well as a 200. Missing headers on error responses is the single most common regression.
Parsing and grading happen entirely in your browser; the tool never fetches your site or transmits the headers you paste. That means you can safely audit a staging environment behind a VPN, or a response containing session cookies.
Two related hardening steps sit just outside the header layer: verifying that inbound webhooks are genuinely signed, covered in the HMAC-SHA256 signature guide, and validating token claims properly, covered in the JWT security guide.
Frequently Asked Questions
Which HTTP security headers actually matter most? ▼
Four carry most of the value. Content-Security-Policy limits which scripts can execute and is the primary defence against cross-site scripting. Strict-Transport-Security forces HTTPS and prevents downgrade attacks. X-Content-Type-Options with the value nosniff stops MIME confusion attacks. Either CSP frame-ancestors or X-Frame-Options prevents clickjacking. Referrer-Policy and Permissions-Policy are valuable but reduce data leakage rather than blocking an active attack.
Why is unsafe-inline ignored in my Content-Security-Policy? ▼
That behaviour is specified, not a bug. When a directive contains a nonce or a hash source expression, the browser ignores unsafe-inline for that directive. The design intent is that a page can send unsafe-inline as a fallback for very old browsers while modern browsers enforce the stricter nonce-based policy. If you want inline scripts to run in a modern browser, you must give each one the matching nonce or hash.
What does HSTS preload require? ▼
To be accepted onto the browser preload list at hstspreload.org, a site must serve a valid certificate, redirect all HTTP traffic to HTTPS on the same host, and send a Strict-Transport-Security header on the HTTPS root with a max-age of at least 31536000 seconds, plus the includeSubDomains and preload tokens. Preloading is difficult to reverse quickly, so confirm that every subdomain can serve HTTPS before submitting.
Is X-Frame-Options still needed if I use CSP frame-ancestors? ▼
It is largely redundant in current browsers, which prefer frame-ancestors when both are present. Many organisations still send both, because X-Frame-Options remains the header that legacy scanners, corporate proxies, and older embedded browsers understand. Sending both is harmless provided they agree; a DENY value alongside a permissive frame-ancestors list is a configuration conflict waiting to confuse an audit.
Can I set security headers with an HTML meta tag instead? ▼
Only partially. A Content-Security-Policy can be delivered via meta http-equiv, but the frame-ancestors, report-uri, and sandbox directives are ignored in that form, and the policy applies only after the parser reaches the tag. No other security header works as a meta tag at all. Send security headers from the server or the edge, where they cover every response including redirects and errors.