Skip to content
CloudOps
Newsletter
All guides
AI for DevOps Security & Hardening By James Joyner IV · · 10 min read

Hardening HTTP Security Headers and CSP Without Breaking Your App

A practical guide to hardening HTTP security headers and rolling out a Content-Security-Policy from report-only to enforced, with Caddy and edge worker config.

  • #security
  • #hardening
  • #web-security
  • #csp
  • #http-headers

The first time I shipped a “tight” Content-Security-Policy to production, I broke my own checkout flow for about nine minutes. A third-party payment script loaded from a domain I’d forgotten to allowlist, the browser silently refused it, and the only signal I had was a graph of failed conversions. That outage taught me the lesson this whole post is built around: the header layer is one of the highest-leverage, lowest-cost defenses you can add to a web app, and also one of the easiest to get subtly, expensively wrong.

Security headers don’t replace input validation, auth, or a patched stack. They’re defense in depth: a second wall that limits the blast radius when something upstream slips. Get them right and you neutralize entire classes of attacks — XSS injection, clickjacking, MIME sniffing, protocol downgrade. Get them wrong and you either break your own site or ship a policy so permissive it does nothing. Below is the exact, defensive approach I use now, including how I lean on an AI assistant to review policies without ever handing it a single real secret.

Start With the Cheap, Unbreakable Headers

Some headers carry almost zero risk of breaking your app, so apply them first and build confidence. In Caddy, header directives live in your site block:

example.com {
  header {
    # HSTS: force HTTPS for a year, all subdomains, eligible for preload
    Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
    # Stop browsers guessing content types
    X-Content-Type-Options "nosniff"
    # Don't leak full URLs to other origins
    Referrer-Policy "strict-origin-when-cross-origin"
    # Lock down powerful browser features by default
    Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=()"
    # Quietly remove fingerprinting headers
    -Server
    -X-Powered-By
  }
}

A word of caution on HSTS preload: only add it once you are certain every subdomain serves HTTPS, because submitting to the preload list is hard to undo and bakes the rule into browsers for everyone. The max-age is in seconds; 31536000 is one year, which the preload list requires. Stripping Server and X-Powered-By won’t stop a determined attacker, but it removes free reconnaissance and version fingerprinting.

CSP Is the Hard One — Treat It Like a Deployment

Content-Security-Policy is where the real XSS protection lives, and it’s the header most likely to break your site. The core idea: instead of trusting any inline script the page contains, you tell the browser exactly which sources are allowed to execute.

A reasonable starting policy looks like this:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-r4nd0m';
  style-src 'self';
  img-src 'self' data:;
  connect-src 'self' https://plausible.example.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none'

default-src 'self' is the fallback for everything you don’t specify. frame-ancestors 'none' is the modern replacement for X-Frame-Options — it stops your page being framed for clickjacking. object-src 'none' kills legacy plugin vectors. The interesting part is script-src.

Nonces vs Hashes: How to Allow Your Own Scripts Safely

The dangerous escape hatch is 'unsafe-inline'. If you allow it, any injected <script> runs, and your CSP gives you almost nothing against XSS. Avoid it. Instead, use nonces or hashes.

A nonce is a random token generated fresh on every request, placed in both the header and the matching script tag:

<script nonce="r4nd0m">/* your trusted inline script */</script>

The browser runs only inline scripts whose nonce attribute matches the header value. Because it’s regenerated per response, an attacker injecting markup can’t guess it. The catch: nonces require server-side rendering to inject a fresh value each time — they don’t work on a fully static, cached HTML page.

For static sites, hashes are better. You compute the SHA-256 of each trusted inline script’s exact contents and list it:

script-src 'self' 'sha256-B2yPHKaXnvFWtRChIbabYmUBFZdVfKKXHbWtWidDVF8='

Pro Tip: Generate a script’s hash from the command line so you never guess it: printf '%s' 'console.log("hi")' | openssl dgst -sha256 -binary | openssl base64. The bytes hashed must be byte-for-byte what ships, including whitespace — one stray newline and the browser blocks it.

Roll Out Report-Only Before You Enforce

This is the step people skip, and it’s the one that saved my checkout flow the second time around. Ship the policy in Content-Security-Policy-Report-Only mode first. In this mode the browser does not block anything — it only reports what would have been blocked. You collect violations from real traffic, fix your allowlist, then flip to enforcing.

Set up a reporting endpoint with both the old report-uri and the newer report-to:

header {
  Reporting-Endpoints `csp-endpoint="https://example.com/csp-reports"`
  Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; report-uri /csp-reports; report-to csp-endpoint"
}

Watch the /csp-reports endpoint for a week or two. Every blocked resource you didn’t expect is either (a) a legitimate dependency you forgot, or (b) something genuinely suspicious worth investigating. Once the noise drops to zero, rename the header from ...-Report-Only to Content-Security-Policy and you’re enforcing with confidence. Keep the reporting directives in place even after enforcing — they’re your early-warning system for both regressions and live injection attempts.

The Same Policy at the Edge

If you terminate at a CDN or run an edge worker, you can apply identical headers there — often the cleaner place, since it covers every origin behind it. Here’s a Cloudflare-style worker that injects a per-request nonce:

export default {
  async fetch(request, env) {
    const response = await fetch(request);
    const nonce = btoa(crypto.randomUUID());
    const headers = new Headers(response.headers);

    headers.set(
      "Content-Security-Policy",
      `default-src 'self'; script-src 'self' 'nonce-${nonce}'; ` +
      `object-src 'none'; frame-ancestors 'none'; base-uri 'self'`
    );
    headers.set("Strict-Transport-Security",
      "max-age=31536000; includeSubDomains; preload");
    headers.set("X-Content-Type-Options", "nosniff");
    headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
    headers.delete("Server");
    headers.delete("X-Powered-By");

    return new Response(response.body, { ...response, headers });
  },
};

For the nonce to actually work, your HTML rendering layer has to read that same value and stamp it onto trusted script tags — worker-injected nonces only help when origin and edge agree on the token.

Isolation and Integrity: COOP, COEP, CORP, and SRI

Three cross-origin headers harden process isolation and are required if you want SharedArrayBuffer or precise timers:

header {
  Cross-Origin-Opener-Policy "same-origin"
  Cross-Origin-Embedder-Policy "require-corp"
  Cross-Origin-Resource-Policy "same-origin"
}

COOP: same-origin severs the link between your window and cross-origin popups (it blunts certain side-channel and tabnabbing attacks). COEP: require-corp means every subresource must explicitly opt in via CORP or CORS. Roll these out carefully — require-corp will break third-party embeds that don’t send the right headers, so test in report-only-style staging first.

Finally, Subresource Integrity for any script or stylesheet you load from a CDN. SRI pins the exact bytes so a compromised CDN can’t swap in malicious code:

<script src="https://cdn.example.com/lib.js"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
  crossorigin="anonymous"></script>

Verify From the Outside

After every change, confirm what the server actually sends — config and reality drift. The fastest check is curl:

curl -sI https://example.com | grep -iE 'content-security|strict-transport|x-content-type|referrer-policy|permissions-policy'

If a header is missing, your directive isn’t matching the request path or is being overwritten downstream. For a richer view, run the response through an external header analyzer, but curl -I is your ground truth.

This is also where an AI assistant earns its place. I treat a model like a fast junior engineer for auditing a CSP: paste the policy string, ask it to flag every wildcard, every 'unsafe-inline', and every source that defeats the policy’s purpose, and have it explain what each directive permits. It’s genuinely good at catching the script-src https: that quietly allows half the internet. But a wrong CSP breaks the site, so a human verifies and applies — never auto-deploy a generated policy. And keep it defensive: never paste real secrets, signing keys, or internal hostnames into a prompt; sanitize first. If you want a repeatable review loop, the code review dashboard and a saved prompt workspace make CSP audits a checklist instead of a guess, and a curated prompt pack gives you vetted audit prompts to start from.

Conclusion

Security headers are the rare hardening win that’s cheap to apply and hard to attack through. Start with the unbreakable ones — HSTS, nosniff, Referrer-Policy, Permissions-Policy — then treat CSP like a real deployment: report-only first, watch the violation stream, tighten the allowlist, enforce. Lean on a tool like claude-ai to review the policy, lean on curl -I to verify it, and lean on a human to press deploy. For more in this vein, browse the security-hardening category and the prompts library.

Free download · 368-page PDF

Download the Free 500-Prompt DevOps AI Toolkit

500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.

  • 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
  • Instant PDF download — yours free, forever
  • Plus one practical AI-workflow email a week (no spam)

Single opt-in · unsubscribe anytime · no spam.