Why Security Headers Still Matter
Security headers are HTTP response headers that instruct browsers to enable (or restrict) specific security behaviours. They cost nothing to implement, require no JavaScript, and provide meaningful protection against entire classes of attacks — cross-site scripting, clickjacking, MIME-type confusion, data injection, and unintended feature access.
Despite this, the majority of websites deploy them incorrectly or not at all. Observatory by Mozilla and SecurityHeaders.com scans consistently show that most sites either omit critical headers or configure them so loosely that they provide no practical protection.
The problem is not awareness. It is that getting headers right — especially Content Security Policy — requires understanding both the security model and your own site's resource-loading behaviour. A misconfigured CSP breaks functionality. A broken site gets the CSP removed. The net result is no protection.
This article provides practical, tested configurations for static sites that you can deploy with confidence. Start strict, test thoroughly, relax only where necessary.
Content Security Policy (CSP)
CSP is the most powerful and most complex security header. It controls which resources the browser is permitted to load — scripts, styles, images, fonts, frames, and more. A well-configured CSP effectively eliminates reflected and stored XSS attacks, even when the application has injection vulnerabilities.
The Report-Only Strategy
Never deploy a new CSP in enforcement mode immediately. Use Content-Security-Policy-Report-Only first:
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self'; report-uri /csp-report; report-to csp-endpoint
This tells the browser to evaluate the policy and report violations without actually blocking anything. You can see exactly what would break before committing to enforcement.
Setting Up Violation Reporting
Configure the Reporting-Endpoints header (the modern replacement for report-uri):
Reporting-Endpoints: csp-endpoint="https://your-domain.com/csp-report"
For static sites that cannot process POST requests, use a third-party reporting service:
- Report URI (report-uri.com) — free tier available
- Sentry — CSP reporting built into error tracking
- uri.report — lightweight alternative
Alternatively, use browser DevTools during development. The Console panel shows CSP violations in real time, which is sufficient for static sites with predictable resource patterns.
Practical CSP for Static Sites
A static site that loads all resources from its own origin has the simplest possible CSP:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
This policy says:
- All resource types default to same-origin only (
default-src 'self') - Scripts, styles, images, and fonts must come from the same origin
- No connections to external APIs (
connect-src 'self') - The page cannot be framed by anyone (
frame-ancestors 'none') <base>tags can only point to the same origin- Forms can only submit to the same origin
When You Need External Resources
Most real-world sites load at least some external resources. Here is how to add them safely:
Google Fonts:
style-src 'self' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com
Better: self-host the fonts and keep the strict policy.
Analytics (e.g., Plausible, Fathom):
script-src 'self' https://plausible.io;
connect-src 'self' https://plausible.io
Embedded videos (YouTube):
frame-src https://www.youtube-nocookie.com
CDN-hosted libraries:
script-src 'self' https://cdn.jsdelivr.net;
style-src 'self' https://cdn.jsdelivr.net
Better: use SRI hashes (see below) and consider self-hosting.
CSP Directives You Should Not Forget
frame-ancestors 'none' — prevents clickjacking. This replaces the older X-Frame-Options header. Use 'none' unless you specifically need to allow framing.
base-uri 'self' — prevents <base> tag injection, which can redirect all relative URLs to an attacker-controlled origin.
form-action 'self' — prevents form hijacking. Without this, an injected form can submit to any origin.
upgrade-insecure-requests — instructs the browser to upgrade HTTP requests to HTTPS automatically. Useful as a belt-and-braces measure alongside HSTS.
block-all-mixed-content — deprecated in favour of upgrade-insecure-requests, but still useful for older browsers.
Avoiding 'unsafe-inline' and 'unsafe-eval'
These two directives effectively disable CSP's XSS protection for scripts:
'unsafe-inline'permits inline<script>tags and event handlers — exactly what XSS attacks inject'unsafe-eval'permitseval(),Function(), and similar dynamic code execution
For static sites, you should never need either. Move all JavaScript to external files. Remove inline event handlers (onclick, onload) and replace them with addEventListener in external scripts.
If a third-party widget absolutely requires inline scripts, use nonces or hashes:
script-src 'self' 'nonce-randomvalue123'
<script nonce="randomvalue123">
// This specific inline script is permitted
</script>
For truly static sites served from a CDN or static host, generating per-request nonces requires edge computing (Cloudflare Workers, Netlify Edge Functions). If this is impractical, use CSP hashes instead:
# Generate the SHA-256 hash of the inline script content
echo -n 'console.log("hello")' | openssl dgst -sha256 -binary | openssl base64
# Output: LCa0a2j/xo/5m0U8HTBBNBNCLXBkg7+g+YpeiGJm564=
script-src 'self' 'sha256-LCa0a2j/xo/5m0U8HTBBNBNCLXBkg7+g+YpeiGJm564='
Subresource Integrity (SRI)
SRI allows the browser to verify that a fetched resource (script or stylesheet) has not been tampered with. You provide a cryptographic hash of the expected content. If the delivered content does not match, the browser refuses to execute it.
How SRI Works
<script src="https://cdn.example.com/library.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8w"
crossorigin="anonymous"></script>
The integrity attribute contains one or more hash values (SHA-256, SHA-384, or SHA-512). The crossorigin="anonymous" attribute is required for cross-origin resources to enable CORS-based integrity checking.
Generating SRI Hashes
# Generate SRI hash for a remote resource
curl -s https://cdn.example.com/library.js | openssl dgst -sha384 -binary | openssl base64 -A
# Prefix with the algorithm: sha384-<hash>
# Or use the srihash.org web tool for quick checks
For build-time generation in an Eleventy project, use a transform or shortcode:
const crypto = require('crypto');
function generateSRI(content) {
const hash = crypto.createHash('sha384').update(content).digest('base64');
return `sha384-${hash}`;
}
SRI Checklist
- [ ] Add SRI to every
<script>and<link rel="stylesheet">that loads from a CDN or third-party origin - [ ] Include the
crossorigin="anonymous"attribute on cross-origin resources with SRI - [ ] Use SHA-384 or SHA-512 — SHA-256 is acceptable but the longer hashes provide more collision resistance
- [ ] Update hashes when upgrading library versions — an outdated hash will block the new version
- [ ] Consider self-hosting if SRI management becomes burdensome — self-hosted resources under your CSP's
'self'directive do not need SRI
HTTP Strict Transport Security (HSTS)
HSTS instructs browsers to only connect to your site over HTTPS, preventing protocol-downgrade attacks and SSL-stripping.
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
max-age=63072000— remember for two years (in seconds)includeSubDomains— apply to all subdomainspreload— eligible for inclusion in browsers' built-in HSTS preload lists
HSTS Deployment Steps
- Ensure HTTPS works correctly on your domain and all subdomains first
- Start with a short max-age (e.g., 300 seconds) to test
- Increase gradually — 86400 (1 day), 604800 (1 week), 2592000 (30 days)
- Add
includeSubDomainsonly after verifying all subdomains support HTTPS - Add
preloadand submit to hstspreload.org only after confirming everything works at the full max-age
Warning: HSTS with includeSubDomains and a long max-age is difficult to undo. If a subdomain cannot serve HTTPS, visitors will be unable to reach it for the duration of the max-age. Test thoroughly before committing.
Permissions-Policy
Permissions-Policy (formerly Feature-Policy) controls which browser features your site can use. For static sites, you almost certainly do not need camera, microphone, geolocation, or most other features.
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()
The empty parentheses () mean "deny to all origins, including self." This is the strictest setting.
If you embed YouTube videos, you may need:
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), fullscreen=(self "https://www.youtube-nocookie.com")
Additional Security Headers
X-Content-Type-Options
X-Content-Type-Options: nosniff
Prevents MIME-type sniffing. The browser will not try to interpret a resource as a different type than what the Content-Type header declares. This prevents attacks where an attacker uploads a file with a .jpg extension but text/html content.
Always set this. There is no reason not to.
Cross-Origin Headers: CORP, COEP, COOP
These three headers control cross-origin resource sharing and process isolation:
Cross-Origin-Resource-Policy (CORP):
Cross-Origin-Resource-Policy: same-origin
Controls whether other origins can load your resources. same-origin prevents other sites from embedding your images, scripts, or stylesheets. Use cross-origin if you intentionally serve resources to other sites.
Cross-Origin-Embedder-Policy (COEP):
Cross-Origin-Embedder-Policy: require-corp
Requires all cross-origin resources to explicitly opt in (via CORP or CORS). Enables SharedArrayBuffer and high-resolution timers. Warning: This breaks loading of cross-origin resources that do not send CORP or CORS headers — including many CDN-hosted images and third-party widgets.
For most static sites, omit COEP unless you specifically need SharedArrayBuffer. The breakage potential is high and the benefit for typical static content is minimal.
Cross-Origin-Opener-Policy (COOP):
Cross-Origin-Opener-Policy: same-origin
Isolates your browsing context from cross-origin popups. Prevents attacks that use window.opener to access your page's context. Safe to deploy for most static sites.
Practical Starter Configuration
For Static Sites on Cloudflare Pages
In your _headers file:
/*
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
For Nginx
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" 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=(), payment=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
Common Breakage Patterns and Fixes
| Symptom | Likely Cause | Fix |
|---|---|---|
| Inline styles not applying | style-src missing 'unsafe-inline' |
Move styles to external CSS files, or add specific hashes |
| Google Fonts not loading | font-src missing https://fonts.gstatic.com |
Add to font-src, or self-host fonts |
| Analytics not sending data | connect-src missing analytics domain |
Add analytics domain to connect-src |
| Embedded videos not showing | frame-src missing video provider domain |
Add provider domain to frame-src |
| Images broken | img-src missing data: for inline images |
Add data: to img-src if you use data URIs |
| Form submissions failing | form-action too restrictive |
Add the form endpoint domain to form-action |
| Third-party widgets broken | COEP require-corp blocking cross-origin resources |
Remove COEP unless specifically needed |
Testing and Verification
Browser DevTools
- Open DevTools (F12) → Console tab
- Load your page — CSP violations appear as error messages with the directive that was violated and the resource that was blocked
- Network tab → check that all resources load successfully (no blocked requests)
Online Scanners
- Mozilla Observatory — comprehensive header analysis with grading
- SecurityHeaders.com — quick header check with recommendations
- CSP Evaluator — Google's CSP-specific analysis tool
Command-Line Verification
# Check all security headers
curl -sI https://your-domain.com | grep -iE "content-security|strict-transport|x-content-type|x-frame|referrer-policy|permissions-policy|cross-origin"
# Verify CSP is not in report-only mode (after you've switched to enforcement)
curl -sI https://your-domain.com | grep -i "content-security-policy"
# Should show "Content-Security-Policy:", NOT "Content-Security-Policy-Report-Only:"
Automated CI Checks
# Using curl in CI to verify headers after deployment
RESPONSE=$(curl -sI https://your-domain.com)
echo "$RESPONSE" | grep -q "Content-Security-Policy:" || echo "FAIL: CSP header missing"
echo "$RESPONSE" | grep -q "Strict-Transport-Security:" || echo "FAIL: HSTS header missing"
echo "$RESPONSE" | grep -q "X-Content-Type-Options: nosniff" || echo "FAIL: X-Content-Type-Options missing"
Gotchas
CSP style-src 'self' breaks inline styles. This includes style="" attributes in HTML. Many CMS outputs and even some Markdown renderers add inline styles. Audit your HTML output before enforcing.
X-Frame-Options: DENY and frame-ancestors 'none' are redundant. Both prevent framing. Use frame-ancestors in CSP (it is more flexible) and keep X-Frame-Options for older browsers that do not support CSP Level 2.
HSTS preload is permanent (practically). Removing your domain from browser preload lists takes months. Only submit after thorough testing.
Referrer-Policy affects analytics. no-referrer strips all referrer information, making it impossible to track where visitors come from. strict-origin-when-cross-origin is a good balance — it sends the origin (but not the full path) for cross-origin requests.
Headers set at the CDN/edge may override or duplicate headers set by the origin. Check for duplicate headers in the response. Some CDNs merge, some replace, some append.
Further Reading
- MDN: Content Security Policy — comprehensive reference
- W3C: Subresource Integrity — specification
- MDN: Strict-Transport-Security
- HSTS Preload Submission
- DDoS Readiness for 2026 — related infrastructure hardening
- Post-Quantum Readiness — TLS configuration context
- Security hub — all security articles
- Infrastructure hub — hosting and infrastructure resources