HTTP security headers are the cheapest security control you will ever deploy: a handful of response headers, set once at the web server or CDN, that instruct every visiting browser to refuse whole classes of attack - clickjacking, protocol downgrade, MIME sniffing, cross-site data leaks and large parts of cross-site scripting. Yet the majority of websites still ship without them, or with values copied from a blog post that silently break functionality.
This guide walks an IT manager through a complete, staged hardening of a production website's headers: what each header does, the exact values to deploy, how to roll out Content-Security-Policy without breaking the site, web-server configuration for nginx and Apache, and how to verify the result. Budget one working day of effort spread over two to three weeks of calendar time (the CSP report-only period accounts for most of it).
How headers protect users
Every header in this guide is an instruction from your server to the visitor's browser. The browser enforces it locally - no appliance, agent or code change required:
Your server Visitor's browser
----------- -----------------
HTTP/1.1 200 OK
Strict-Transport-Security ------> "Never load this site over HTTP again"
Content-Security-Policy ------> "Only run scripts from these origins"
X-Frame-Options ------> "Refuse to render this site in an iframe"
X-Content-Type-Options ------> "Never guess file types"
Referrer-Policy ------> "Don't leak full URLs to other sites"
Permissions-Policy ------> "This site may not use camera/mic/location"
Because enforcement happens in the browser, headers protect your users even when the attack does not touch your server at all - for example a phishing page framing your login form, or an injected ad script exfiltrating form data.
Step 1 - Baseline: measure before you change anything
Run your site through a header scanner - FortifyNet's free security headers checker grades each header and shows a one-line fix, and securityheaders.com is a good second opinion. Save the result. A typical unhardened site scores an F with only Server and X-Powered-By present - both of which leak information rather than protect anything.
Also inventory what the site actually loads. Open the browser dev tools Network tab on your five most important pages and note every third-party origin: analytics, fonts, tag managers, chat widgets, payment iframes, video embeds. This list becomes your CSP allowlist in Step 4.
Step 2 - Deploy the five safe headers immediately
Five headers are effectively risk-free for almost every site and should go out in the first change window.
Strict-Transport-Security (HSTS)
Forces HTTPS for every future visit, defeating SSL-stripping attacks on public Wi-Fi.
Strict-Transport-Security: max-age=31536000; includeSubDomains
Start with max-age=31536000 (one year) only if all subdomains already serve HTTPS; otherwise begin with max-age=86400 and includeSubDomains omitted, then expand. Once stable, consider HSTS preload - being hard-coded into browsers removes even the first-visit window, but is effectively irreversible, so treat preload as a one-way door.
X-Content-Type-Options
X-Content-Type-Options: nosniff
Stops browsers from "sniffing" file types, which prevents a user-uploaded file served as text from being executed as script. There is no legitimate reason to omit this.
X-Frame-Options
X-Frame-Options: DENY
Blocks clickjacking - an attacker overlaying your real, framed site with invisible buttons. Use SAMEORIGIN instead if your own site legitimately frames itself (some admin panels and preview features do). Modern CSP frame-ancestors supersedes this header, but keep both for older browsers.
Referrer-Policy
Referrer-Policy: strict-origin-when-cross-origin
Ensures links to external sites reveal only your origin (https://example.com) rather than the full URL - which may contain search terms, account paths or reset tokens. This value is also the modern browser default, but setting it explicitly guarantees it everywhere.
Permissions-Policy
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
Declares which powerful browser features the site (and any injected script) may use. Deny everything you do not use; a compromised third-party script then cannot silently request the camera. Add features back (e.g. payment=(self "https://js.stripe.com")) only where genuinely needed.
Step 3 - Remove headers that leak
While editing configuration, strip the chatty defaults: Server version strings and X-Powered-By. They provide zero function and hand attackers your exact software versions for CVE matching. In nginx: server_tokens off;. In Apache: ServerTokens Prod and ServerSignature Off. For PHP: expose_php = Off.
Step 4 - Content-Security-Policy, the staged way
CSP is the most powerful header - and the only one that can break your site if deployed carelessly. Never copy a CSP from another site. Follow this three-stage process instead.
Stage A: Report-Only (1–2 weeks)
Deploy the policy in Content-Security-Policy-Report-Only mode. Browsers evaluate it and report violations, but block nothing:
Content-Security-Policy-Report-Only: default-src 'self';
script-src 'self' https://www.googletagmanager.com https://js.stripe.com;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: https:;
frame-src https://js.stripe.com https://www.youtube-nocookie.com;
connect-src 'self' https://www.google-analytics.com;
frame-ancestors 'none';
report-uri /csp-report
Build the allowlist from your Step 1 inventory. Point report-uri at a small endpoint (or a hosted service) and let real traffic exercise every page, including the checkout and the rarely-visited account pages, for at least a week.
Stage B: Fix the violations
Review the reports and dev-tools console. The usual findings:
| Violation | Cause | Fix |
|---|---|---|
| inline script blocked | onclick= handlers, inline script tags | move to external files, or add per-response nonces |
| eval blocked | old libraries, some tag managers | upgrade library; avoid 'unsafe-eval' |
| unknown origin | a script loading a script | add the origin, or drop the widget |
| data: font | icon fonts embedded in CSS | allow font-src data: or switch to SVG icons |
The gold standard for scripts is nonces: the server generates a random value per response, adds it to the header (script-src 'nonce-R4nd0m') and to each legitimate script tag. Injected scripts lack the nonce and die. 'strict-dynamic' (CSP Level 3) then lets a nonce-approved script load its own dependencies - this is Google's recommended pattern and dramatically shrinks the allowlist.
Accept 'unsafe-inline' for styles if removing it is disproportionate work - the practical risk of inline CSS is far lower than inline JS. Do not accept 'unsafe-inline' for scripts; it disables the header's main value.
Stage C: Enforce
Rename the header to Content-Security-Policy, keep the reporting endpoint active permanently, and watch the first 48 hours. Any regression is one config line away from rollback to Report-Only.
Step 5 - Web-server configuration
Set headers in one central place - the web server or CDN - not per-application, so every response (including error pages and static assets) is covered.
nginx (inside the server block; always is required so headers also attach to 4xx/5xx responses):
server_tokens off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; ..." always;
Beware the classic nginx trap: add_header directives are not inherited if a location block declares its own add_header. Keep them all at server level, or repeat the full set.
Apache (in the vhost or .htaccess, with mod_headers enabled):
ServerTokens Prod
ServerSignature Off
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "DENY"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
Header always set Content-Security-Policy "default-src 'self'; ..."
On CDNs (Cloudflare, CloudFront, Vercel, Netlify) use their response-header transforms so the edge applies the same set to cached responses.
Step 6 - Verify and keep verifying
After each change: hard-refresh with dev tools open and read the actual response headers; then re-run the headers checker and compare against the Step 1 baseline. A well-hardened site reaches grade A.
Headers rot. A redesign adds a widget that CSP blocks; a migration to a new load balancer silently drops the whole set. Put verification on a schedule - FortifyNet's continuous monitoring re-checks headers and alerts on any change, which is exactly the "config drift" failure mode that manual processes miss.
Cookie hardening - the forgotten response headers
Session cookies are set by response headers too, and a hardened header set is incomplete without them. Three attributes on every Set-Cookie:
\
Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=Lax; Path=/
\\
- Secure - the cookie is never sent over plain HTTP. With HSTS in place this closes the loop entirely.
- HttpOnly - JavaScript cannot read the cookie, so even a successful XSS cannot exfiltrate the session token directly.
- SameSite=Lax (or Strict) - the browser withholds the cookie on cross-site requests, neutralizing most CSRF attacks at zero code cost. Use SameSite=None; Secure only for cookies that genuinely must travel cross-site (embedded widgets, SSO flows).
Consider cookie prefixes for defense in depth: a cookie named __Host-session is only accepted by browsers if it is Secure, has no Domain attribute and Path=/ - making it impossible for a compromised subdomain to overwrite it.
Cross-origin isolation - COOP, CORP and CORS sanity
Three newer headers control how your site interacts with other origins at the browser level:
| Header | Recommended value | What it prevents |
|---|---|---|
| Cross-Origin-Opener-Policy | same-origin | other sites holding a scriptable reference to your window (tabnabbing, XS-Leaks) |
| Cross-Origin-Resource-Policy | same-origin (or same-site) | other origins embedding your resources (data theft via inclusion) |
| Cross-Origin-Embedder-Policy | require-corp (advanced) | loading cross-origin resources that haven't opted in |
COOP: same-origin and CORP: same-origin are safe for most sites and worth deploying in the same change as Step 2. COEP is stricter - it breaks any cross-origin image or script that does not send CORP headers itself - so deploy it only if you need cross-origin isolation (e.g. for SharedArrayBuffer) and test in Report-Only first (Cross-Origin-Embedder-Policy-Report-Only exists for exactly this).
While here, sanity-check CORS: Access-Control-Allow-Origin: * on an API that uses cookies or returns user data is a real vulnerability, not a convenience. Allow specific origins, and never reflect the request's Origin header back unvalidated.
Caching sensitive responses
Header hardening includes making sure private data is not stored where you cannot delete it. For any authenticated or personal-data response:
\
Cache-Control: no-store
\\
no-store beats the older Pragma and Expires combinations and prevents both browser and intermediary caching. The classic failure: a logout button that works, but the back button still shows the account page from browser cache - no-store on authenticated pages fixes it. Keep long-lived caching (Cache-Control: public, max-age=31536000, immutable) for fingerprinted static assets; the two policies coexist per-route.
Notes for SPAs and frameworks
Single-page applications change the CSP calculus slightly:
- The HTML shell is served once, so nonces must be generated per response at the edge or server - a static site generator that bakes one nonce into the build has zero security value. If the site is fully static, use hashes (sha256-…) of the specific inline scripts instead of nonces.
- Hydration frameworks (Next.js, Nuxt) inline state as script tags; both support nonce propagation from middleware - use the framework's documented CSP integration rather than fighting it.
- connect-src is the SPA's most active directive: every API origin, websocket endpoint and telemetry sink must be listed. Missing entries fail silently for users (requests blocked, features dead) - another reason the Report-Only stage is non-negotiable.
- For APIs consumed by browsers, apply the same base headers to API responses: nosniff, no-store where relevant, and a restrictive CSP (default-src 'none') on any endpoint that could ever be opened directly in a browser tab.
Verifying from the command line
Scanners are convenient, but you should also be able to verify headers directly - especially when debugging why a header is present on one route but missing on another (the nginx inheritance trap, a CDN transform applied to only one path pattern, or an application framework overriding the server). Use curl with the head-request flag (uppercase I), or the Network panel in the browser's developer tools, and read the raw response headers for three URLs: the homepage, an authenticated-area route, and a page that does not exist. A healthy response looks like this:
\
HTTP/2 200
content-security-policy: default-src 'self'; ...
permissions-policy: camera=(), microphone=(), geolocation=()
referrer-policy: strict-origin-when-cross-origin
strict-transport-security: max-age=31536000; includeSubDomains
x-content-type-options: nosniff
x-frame-options: DENY
\\
Check all three cases: the homepage, an authenticated-area route, and a 404. Repeat the check over both HTTP and HTTPS, and on the www and bare-domain variants if both are served - redirects frequently come from a different configuration block that carries none of the hardening, and an SSL-stripping attack begins at exactly that unprotected first hop. The error page is the other classic gap - frameworks often serve error responses through a different code path that skips middleware, and an attacker probing your site sees mostly error pages. If the 404 lacks headers that the homepage has, the set is not applied centrally enough; move it to the server or edge layer.
Two more habits worth automating:
- CI check: a five-line script in the deployment pipeline that curls the staging URL and fails the build if an expected header is missing. This catches the "new load balancer dropped everything" regression before production does.
- Change alerting in production: external monitoring that re-reads the live header set on a schedule and alerts on any difference - this is part of FortifyNet's continuous monitoring, and it is how you learn about the regression that CI could not see, such as a CDN configuration edit made directly in the provider's dashboard.
Common myths and mistakes
- "We have a WAF, headers are redundant." A WAF inspects traffic to your server; headers control what the browser does with your pages, including attacks that never touch your infrastructure. They are complementary layers.
- "X-XSS-Protection should be enabled." No - the header is deprecated; the auditor that flags its absence is out of date. Modern browsers removed the XSS auditor it controlled, and in old browsers it introduced vulnerabilities. Set it to 0 or omit it entirely; CSP is its replacement.
- "CSP broke the site once, so we removed it." The failure was skipping Report-Only, not CSP itself. Re-run the staged process.
- "Grade A means secure." Headers are one layer. An A-grade site with an unpatched CMS is still one plugin CVE from compromise - which is why the header scan is one test among several in a full FortifyNet audit.
Rollout checklist
- [ ] Baseline scan saved; third-party origin inventory complete
- [ ] HSTS, nosniff, X-Frame-Options, Referrer-Policy, Permissions-Policy deployed
- [ ] Server/X-Powered-By version leakage removed
- [ ] CSP running in Report-Only with reporting endpoint
- [ ] Violations triaged; nonces used for scripts; no 'unsafe-inline' for script-src
- [ ] CSP enforced; frame-ancestors set; reporting kept on
- [ ] Headers set centrally (server/CDN), always/Header always used
- [ ] Re-scan shows grade A; continuous monitoring enabled
Sources
- MDN - HTTP response headers reference
- OWASP Secure Headers Project
- W3C - Content Security Policy Level 3
- Google web.dev - Mitigate XSS with a strict CSP
- RFC 6797 - HTTP Strict Transport Security
- hstspreload.org - HSTS preload submission
Originally published at fortifynet.com/blog/guide-security-headers-hardening. I'm the founder of FortifyNet, a website security scanner; this article comes from our blog, so factor in that founder bias when you read any tool recommendations here.
