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' permits eval(), 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 subdomains
  • preload — eligible for inclusion in browsers' built-in HSTS preload lists

HSTS Deployment Steps

  1. Ensure HTTPS works correctly on your domain and all subdomains first
  2. Start with a short max-age (e.g., 300 seconds) to test
  3. Increase gradually — 86400 (1 day), 604800 (1 week), 2592000 (30 days)
  4. Add includeSubDomains only after verifying all subdomains support HTTPS
  5. Add preload and 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

  1. Open DevTools (F12) → Console tab
  2. Load your page — CSP violations appear as error messages with the directive that was violated and the resource that was blocked
  3. Network tab → check that all resources load successfully (no blocked requests)

Online Scanners

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