Government websites have a rough CSP problem.

I’ve seen the same pattern over and over: a site adds a Content Security Policy because a scanner demanded it, ships a giant allowlist, sprinkles in 'unsafe-inline', and calls it done. The header exists, the compliance box gets checked, and the actual XSS risk barely moves.

That’s not a government-specific technical limitation. It’s mostly a procurement, legacy platform, and “don’t break the citizen-facing form” problem.

A real example helps. Here’s a live-style CSP header from headertest.com:

content-security-policy:
  default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  script-src 'self' 'nonce-MjJjYTkxYTMtNGI2Yi00OGMwLTg0YmItM2VmMWJiMzg3OTkw' '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://u.headertest.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 better than the “allow everything from everywhere” policies I usually find. It has object-src 'none', base-uri 'self', form-action 'self', and frame-ancestors 'none'. Good start.

But it also shows some common mistakes that are especially common on public sector sites.

Mistake 1: Treating CSP like a vendor allowlist

Government sites often rely on analytics, consent platforms, booking tools, maps, payment providers, and old CMS plugins. The policy turns into a shopping list of domains.

That’s how you end up with broad entries like:

default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com

The problem: default-src is a fallback. If you stuff third-party domains into it, you may accidentally allow them in places you didn’t intend. That makes reasoning about the policy harder, and government teams already have enough complexity.

Fix

Keep default-src tight. Usually just:

default-src 'self';

Then explicitly allow only what each resource type needs:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
  style-src 'self';
  img-src 'self' data:;
  font-src 'self';
  connect-src 'self' https://api.example.gov;
  frame-src 'self';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';

That structure is easier to audit, easier to explain to an assessor, and much harder to weaken by accident.

If you need starter policies, https://csp-examples.com is useful for comparing patterns.

Mistake 2: Using 'strict-dynamic' but keeping a legacy mindset

The sample header includes:

script-src 'self' 'nonce-...' 'strict-dynamic' https://www.googletagmanager.com ...

This is half-modern and half-legacy.

'strict-dynamic' is strong when used correctly. It tells the browser to trust nonce-approved scripts and the scripts they load, instead of relying on hostname allowlists. That’s usually what you want for modern apps.

But teams often keep piling domains into script-src anyway, because they don’t trust the nonce setup yet or they need old browser compatibility. That’s understandable, but it leads to confused policies.

Fix

Pick a strategy and commit to it:

  • Nonce/hash-based CSP for modern apps
  • Host allowlist CSP for older stacks that can’t generate nonces reliably

For government portals running modern server-side rendering, I’d push hard for nonces.

A clean nonce-based policy looks like this:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
  style-src 'self' 'nonce-{RANDOM_NONCE}';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self' https://api.service.gov;
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';

And then in your HTML:

<script nonce="{{ .CSPNonce }}">
  window.appConfig = {
    apiBase: "/api"
  };
</script>

<script nonce="{{ .CSPNonce }}" src="/assets/app.js"></script>

The nonce must be unique per response. Not per deploy. Not hardcoded in a template. Per response.

Official docs: MDN CSP introduction

Mistake 3: Leaving 'unsafe-inline' in style-src forever

This one is everywhere in government CMS deployments.

From the sample:

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

I get why this happens. Old templates, WYSIWYG editors, inline style attributes, consent banners, accessibility widgets, and vendor snippets all push teams toward 'unsafe-inline'.

But once it’s there, nobody comes back to remove it.

Fix

Treat 'unsafe-inline' as temporary debt, not a permanent feature.

Better options:

  1. Move inline styles into static CSS files.
  2. Use nonces or hashes for unavoidable inline <style> blocks.
  3. Remove style= attributes generated by templates where possible.
  4. Challenge vendors that require insecure integration patterns.

Example with a nonce:

style-src 'self' 'nonce-{RANDOM_NONCE}';
<style nonce="{{ .CSPNonce }}">
  .notice-banner { display: block; }
</style>

If your platform makes this painful, start by rolling out Content-Security-Policy-Report-Only and measure what actually depends on inline styles.

Official docs: CSP style-src

Mistake 4: Allowing img-src https: because it’s easy

The sample policy has:

img-src 'self' data: https:;

This is common because images break constantly on public sites: department logos, uploaded media, partner badges, social icons, and tracking pixels. So teams allow every HTTPS image on the internet.

That’s convenient, but sloppy. It gives attackers more room for abuse and makes data flow harder to govern.

Fix

Be explicit.

img-src 'self' data: https://media.agency.gov https://cdn.agency.gov;

If the site genuinely needs user-submitted remote images, isolate that feature, proxy the images, or host them yourself after validation. Public sector sites usually have stricter data handling requirements anyway, so broad image loading from arbitrary origins is rarely a good long-term choice.

Mistake 5: Forgetting that forms are high-value targets

The sample gets this right:

form-action 'self';

A lot of government sites don’t.

That’s a problem because public services are full of forms: tax, licensing, benefits, complaints, records requests, procurement, and identity workflows. If an attacker gets script execution anywhere in that flow, they’ll try to exfiltrate data or redirect submissions.

Fix

Always set form-action, and keep it narrow.

form-action 'self' https://payments.agency.gov;

If your service posts to a third-party payment or identity provider, list only that endpoint class, not a giant wildcard.

Also set:

base-uri 'self';
frame-ancestors 'none';
object-src 'none';

These three are low-drama, high-value directives. I add them by default unless I have a very specific reason not to.

Official docs: CSP directives reference

Mistake 6: Blocking framing everywhere without checking real use cases

The sample uses:

frame-ancestors 'none';

That’s excellent for many government sites. It blocks clickjacking and prevents other sites from embedding your pages.

But some agencies actually need selective embedding: shared widgets, internal dashboards, or cross-agency portals. I’ve watched teams break legitimate integrations because they copied 'none' from a scanner recommendation without understanding the impact.

Fix

Use 'none' unless there is a real embedding need. If there is, allow only the exact trusted parent origins.

frame-ancestors 'self' https://portal.state.gov;

Don’t use broad wildcards here. This directive is too valuable to weaken casually.

Mistake 7: Rolling out enforcement before using report-only

This is how outages happen.

A government site often has hidden dependencies: one analytics endpoint, one PDF widget, one ancient inline script in the header include from 2014. Turn on an aggressive CSP in enforcement mode without telemetry, and you’ll break something public-facing at the worst possible time.

Fix

Start with report-only:

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
  style-src 'self' 'nonce-{RANDOM_NONCE}';
  img-src 'self' data:;
  connect-src 'self';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';
  report-to csp-endpoint;

Then define reporting:

Reporting-Endpoints: csp-endpoint="https://reports.agency.gov/csp"

Review reports, fix the app, then enforce.

Official docs: Reporting API

Mistake 8: Copy-pasting one CSP across every subdomain

Government estates are messy. Main site, service portals, old apps, microsites, internal admin tools behind SSO, and a few mystery systems nobody wants to touch.

One CSP won’t fit all of them.

Fix

Build policy profiles.

For example:

  • Brochure site policy: very strict, mostly self-hosted assets
  • Transactional service policy: strict forms, limited third-party integrations
  • Legacy app policy: temporary exceptions, aggressive reporting, migration plan

That beats a single bloated policy trying to satisfy every system at once.

A better baseline for government sites

If I were starting from scratch for a typical agency site, I’d use something like this:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
  style-src 'self' 'nonce-{RANDOM_NONCE}';
  img-src 'self' data: https://cdn.agency.gov;
  font-src 'self';
  connect-src 'self' https://api.agency.gov;
  frame-src 'self';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';

Then I’d add exceptions only when a real business requirement exists, not because a vendor onboarding doc said “paste this snippet.”

That’s the part government teams need to get tougher on. Vendors love insecure defaults because they reduce support tickets. You’re the one who has to defend the citizen data, pass the audit, and explain the breach review later.

So be stubborn. Keep the policy small. Prefer nonces over giant host allowlists. Treat 'unsafe-inline' like a bug. And don’t confuse “we set a CSP header” with “we deployed a good CSP.”