A lot of CSP advice sounds clean on paper and falls apart the second you add the usual site accoutrement: analytics, tag managers, cookie consent, forms, embeds, and one “temporary” inline script that survives for two years.

That’s why CSP work gets messy on real sites.

I’m going to use a real-world style policy based on the header observed on headertest.com and walk through what a developer-facing site like csp-examples would look like before and after tightening it up.

Here’s the real CSP header we’re using as the reference point:

content-security-policy:
  default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  script-src 'self' 'nonce-OTE4YmE0MDktZTA5NS00ZjRiLWI3OGEtNmU3NTU0NzVjYjc3' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com;
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self' https://api.headertest.com https://tallycdn.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com;
  frame-src 'self' https://consentcdn.cookiebot.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none'

This is not a toy CSP. It reflects the kind of policy you end up with after adding:

  • Google Tag Manager
  • Google Analytics
  • Cookiebot consent tooling
  • API calls
  • a websocket endpoint
  • embedded consent UI
  • some inline bootstrapping code

That’s normal. The trick is making “normal” not turn into “allow half the internet.”

The setup

Assume csp-examples is a developer education site with:

  • static pages rendered by Hugo
  • a small amount of client-side JS
  • analytics
  • cookie consent
  • a contact form
  • a live search widget hitting an API
  • one embeddable interactive demo

Pretty standard.

Before: the lazy CSP that ships too often

I’ve seen versions of this on production sites more times than I’d like:

Content-Security-Policy:
  default-src * 'unsafe-inline' 'unsafe-eval' data: blob:;

Or the “slightly less embarrassing” version:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com https://www.google-analytics.com;
  style-src 'self' 'unsafe-inline';
  img-src * data:;
  connect-src *;
  frame-src *;

This kind of policy exists because teams add services one by one, break production twice, then loosen everything until the console errors go away.

It works. It also guts the value of CSP.

Problems with this version:

  • 'unsafe-inline' in script-src means injected inline JS can run.
  • 'unsafe-eval' allows eval-like execution paths you usually don’t need.
  • connect-src * is way too broad for API-heavy pages.
  • frame-src * is a gift to third-party sprawl.
  • img-src * sounds harmless until tracking pixels and odd exfil paths show up.
  • No object-src 'none', base-uri, or frame-ancestors.

For a content site with accoutrement, this is the classic “we technically have CSP” situation.

After: a practical CSP with real constraints

Now compare that with a more deliberate policy inspired by the real header:

Content-Security-Policy:
  default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  script-src 'self' 'nonce-{{ .CSPNonce }}' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com;
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self' https://api.csp-examples.test https://analytics.csp-examples.test https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com;
  frame-src 'self' https://consentcdn.cookiebot.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';

This is already much better. It’s still pragmatic, not purist.

My opinion: this is the sweet spot most developer-content sites should aim for first. Not perfect. Defensible.

Why this version is better

1. Nonces beat inline script exceptions

The biggest upgrade is replacing broad inline script allowance with a nonce.

Server generates a fresh nonce per response:

import crypto from "node:crypto";

export function makeNonce() {
  return crypto.randomBytes(16).toString("base64");
}

Then set the header:

const nonce = makeNonce();

res.setHeader(
  "Content-Security-Policy",
  [
    "default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com`,
    "style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com",
    "img-src 'self' data: https:",
    "font-src 'self'",
    "connect-src 'self' https://api.csp-examples.test https://analytics.csp-examples.test https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com",
    "frame-src 'self' https://consentcdn.cookiebot.com",
    "frame-ancestors 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "object-src 'none'"
  ].join("; ")
);

res.locals.cspNonce = nonce;

Then use it in HTML:

<script nonce="{{ .CSPNonce }}">
  window.siteConfig = {
    searchApi: "/api/search",
    telemetry: true
  };
</script>

That’s a huge step up from:

<script>
  window.siteConfig = { searchApi: "/api/search", telemetry: true };
</script>

with 'unsafe-inline' enabled.

2. strict-dynamic is useful when bootstrapping trusted scripts

If your nonced bootstrap script loads a trusted external script, strict-dynamic lets trust flow from that script instead of forcing you to keep broad host allowlists forever.

For example:

<script nonce="{{ .CSPNonce }}">
  const s = document.createElement("script");
  s.src = "https://www.googletagmanager.com/gtm.js?id=GTM-XXXX";
  document.head.appendChild(s);
</script>

That said, don’t treat strict-dynamic as magic dust. You still need to understand which scripts your trusted bootstraps can load. The official docs are worth reading carefully for this one:

https://developer.mozilla.org/docs/Web/HTTP/Headers/Content-Security-Policy/script-src

And for a practical explanation of directives, csp-guide is solid:

https://csp-guide.com/strict-dynamic/

3. connect-src is where modern sites leak scope

Developers usually focus on scripts first. Fair. But connect-src is where a lot of accidental over-permissioning happens.

On a site with search, telemetry, consent, and maybe a websocket demo, it’s easy to end up here:

connect-src *;

That’s bad.

The real-world reference policy uses a narrow list:

connect-src 'self'
  https://api.headertest.com
  https://tallycdn.com
  https://or.headertest.com
  wss://or.headertest.com
  https://*.google-analytics.com
  https://*.googletagmanager.com
  https://*.cookiebot.com;

That’s the right shape. Explicit endpoints, explicit websocket origin, explicit analytics domains.

For csp-examples, I’d keep it equally boring and specific.

The part I would still tighten

The reference CSP is good, but I wouldn’t stop there.

style-src 'unsafe-inline' is still a compromise

This is extremely common because consent tools and legacy UI code inject inline styles.

Still, if I own the site, I try to burn this down over time.

Before:

style-src 'self' 'unsafe-inline' https://*.cookiebot.com;

Target:

style-src 'self' https://*.cookiebot.com https://consent.cookiebot.com;

Or, if you truly need specific inline styles, move toward hashes or a nonce-based approach where supported by your rendering flow. Official CSP docs:

https://developer.mozilla.org/docs/Web/HTTP/Headers/Content-Security-Policy/style-src

I’m opinionated here: 'unsafe-inline' in styles is more tolerable than in scripts, but it should still make you slightly uncomfortable.

img-src https: is pragmatic, but broad

This line from the reference policy is common:

img-src 'self' data: https:;

It avoids breakage from analytics pixels, CMS content, and third-party badges. It also allows any HTTPS image source.

That may be fine for a docs/content site. If your image sources are stable, narrow it:

img-src 'self' data: https://images.csp-examples.test https://www.google-analytics.com;

I usually start broad enough to avoid noise, then tighten after watching what actually gets loaded.

Common breakages during rollout

When teams tighten CSP on a site with accoutrement, these are the usual offenders:

Tag manager injects more than expected

You allow www.googletagmanager.com, but runtime behavior also hits *.googletagmanager.com or analytics collection endpoints under *.google-analytics.com.

Cookie tooling is never just one directive. It tends to need all of these:

  • script-src
  • style-src
  • connect-src
  • frame-src

Miss one and the banner half-renders and silently fails.

Forms post off-origin

If your “contact us” form actually posts to a provider, this will fail under:

form-action 'self';

You need to explicitly allow the submission endpoint:

form-action 'self' https://submit.example-form-provider.test;

Websocket demos fail in production only

Local testing often misses the wss: endpoint entirely. If your interactive demo uses websockets, add it explicitly to connect-src.

A rollout pattern that works

For csp-examples, I’d ship this in two phases.

Phase 1: report-only

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self' 'nonce-{{ .CSPNonce }}' 'strict-dynamic';
  style-src 'self';
  img-src 'self' data:;
  font-src 'self';
  connect-src 'self';
  frame-src 'self';
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';

Then expand only where real violations justify it.

Phase 2: enforce with narrow exceptions

Ship the actual integrations you need, and nothing broader.

That’s the real lesson from the headertest-style header: good CSP on a real site is not about having the smallest policy possible. It’s about knowing exactly which third-party accoutrement you’ve accepted and pinning each one to the smallest workable set of directives.

If you want a quick directive reference while tuning the policy, the official CSP header docs are here:

https://developer.mozilla.org/docs/Web/HTTP/Headers/Content-Security-Policy

And if you want a more practical directive-by-directive map, this is useful:

https://csp-guide.com/

For developer sites, that’s usually enough. Don’t chase theoretical purity while shipping connect-src * and script-src 'unsafe-inline'. Fix the dangerous stuff first, make the third-party footprint explicit, and accept that real-world CSP is part security policy, part inventory management.