I’ve seen a bunch of teams do the same thing with Content Security Policy on Nginx: they add one header at the reverse proxy, ship it, and assume the app is now “protected.” Then analytics breaks, consent banners disappear, WebSocket calls fail, and somebody quietly removes the header on Friday evening.

That usually happens because CSP at the reverse proxy layer is not just “set a header.” It’s policy design, inheritance, proxy behavior, and app rendering all tangled together.

Here’s a real-world style case study of how this tends to go, with a before-and-after setup that actually survives production traffic.

The setup

The stack looked like this:

  • Nginx as the public reverse proxy
  • App upstream running behind it
  • Marketing scripts loaded from Google Tag Manager
  • Cookie consent via Cookiebot
  • Analytics calls to Google Analytics
  • A small API and a WebSocket endpoint
  • Some inline scripts still hanging around in templates

Pretty normal. Also exactly the kind of stack where a lazy CSP causes pain.

The team wanted to enforce CSP at the Nginx layer so they could standardize headers across services. Good instinct. The problem was that they started with a copy-paste policy that was too strict in the wrong places and too loose in others.

The “before” state

Their first attempt looked like this:

server {
    listen 443 ssl http2;
    server_name csp-guide.example.com;

    location / {
        proxy_pass http://app_upstream;
        proxy_set_header Host $host;

        add_header Content-Security-Policy "
            default-src 'self';
            script-src 'self';
            style-src 'self';
            img-src 'self';
            connect-src 'self';
            object-src 'none';
        " always;
    }
}

Looks clean. Also broken.

What failed immediately

  1. Google Tag Manager didn’t load

    • script-src 'self' blocked www.googletagmanager.com
  2. Cookie consent UI broke

    • Cookiebot needed scripts, frames, and styles from its own domains
  3. Analytics stopped sending data

    • connect-src 'self' blocked requests to Google Analytics
  4. Images from external HTTPS URLs failed

    • A bunch of CMS content referenced external assets
  5. Inline bootstrapping scripts were blocked

    • The app still used a small inline config blob in the HTML

This is the classic reverse-proxy CSP mistake: writing policy based on what you wish the app loaded, not what it actually loaded.

The first debugging pass

The team switched to report-only mode first. Good move.

add_header Content-Security-Policy-Report-Only "
    default-src 'self';
    script-src 'self';
    style-src 'self';
    img-src 'self';
    connect-src 'self';
    object-src 'none';
" always;

Then they checked the real response headers and violations. If you want a quick external check of what your proxy is actually returning, HeaderTest is handy for catching header mistakes before you start chasing ghosts in DevTools.

The biggest lesson from that pass: the reverse proxy was the right place to enforce CSP, but not the right place to guess the application’s runtime dependencies.

The actual production policy

After reviewing browser violations and upstream behavior, they landed much closer to this real header structure:

content-security-policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-ODY1NjM2NzUtZWMwMC00NTU5LWJkNjItYmEzNDY0OTY0NmJh' '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 what a grown-up CSP looks like: not minimal, not pretty, but aligned with the app.

A few things I like about it:

  • object-src 'none' is locked down
  • frame-ancestors 'none' blocks clickjacking
  • base-uri 'self' and form-action 'self' are explicitly set
  • connect-src includes both HTTPS and WebSocket targets
  • script-src uses a nonce and strict-dynamic

A few things I’d still treat as transitional:

  • style-src 'unsafe-inline' is often a compromise, not an end state
  • broad host allowances in default-src can usually be tightened over time

If you need starter policies for common stacks, csp-examples.com is useful, but I’d still validate every directive against actual traffic.

The reverse proxy gotcha nobody mentions enough

Here’s the part that burned them for a day: the upstream app was also setting a CSP header on some routes.

That meant some responses had two CSP headers coming back:

  • one from Nginx
  • one from the application

Browsers don’t “merge” those the way people expect. Multiple CSP headers are effectively cumulative restrictions. That can get ugly fast.

So the fix started with stripping upstream CSP and making Nginx the single source of truth:

server {
    listen 443 ssl http2;
    server_name csp-guide.example.com;

    location / {
        proxy_pass http://app_upstream;
        proxy_set_header Host $host;

        proxy_hide_header Content-Security-Policy;
        proxy_hide_header Content-Security-Policy-Report-Only;

        add_header Content-Security-Policy $csp_header always;
    }
}

That one change removed a lot of “why does this route behave differently?” nonsense.

The “after” Nginx config

Here’s the cleaned-up version they shipped.

map $request_id $csp_nonce {
    default $request_id;
}

map "" $csp_header {
    default "default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; \
script-src 'self' 'nonce-$csp_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.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'";
}

server {
    listen 443 ssl http2;
    server_name csp-guide.example.com;

    location / {
        proxy_pass http://app_upstream;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_hide_header Content-Security-Policy;
        proxy_hide_header Content-Security-Policy-Report-Only;

        add_header Content-Security-Policy $csp_header always;
    }
}

This still leaves one practical issue: the nonce has to match the inline script tags in the HTML. Nginx can generate or pass a value, but your upstream app must actually render that nonce into script tags.

For example, the app template needs to do something like:

<script nonce="{{ csp_nonce }}">
  window.appConfig = {
    apiBase: "/api",
    env: "prod"
  };
</script>

And the app has to receive the same nonce value from Nginx or generate the header itself. That integration detail matters more than most CSP tutorials admit.

If your app can’t reliably inject nonces, don’t pretend it can. Use report-only, inventory the inline scripts, and refactor them out. Half-baked nonce deployments are worse than being honest about where you are.

What changed after rollout

Once the policy matched real dependencies, the team got the benefits they wanted:

  • third-party scripts were limited to known domains
  • random inline script injection attempts were blocked
  • clickjacking protection was explicitly enforced
  • plugin-style legacy content using <object> was dead
  • CSP management moved into a single Nginx layer instead of getting duplicated across services

The incident rate also dropped because changes to vendors were now visible. When marketing added a new third-party tag, it failed loudly in report-only during staging instead of silently broadening the browser’s trust boundary.

That’s the underrated value of CSP: not just blocking XSS, but forcing your organization to admit what it actually loads.

What I’d do differently from day one

If I were setting this up fresh on a reverse proxy, I’d do it in this order:

1. Start with report-only

Don’t enforce on day one unless the app is tiny and boring.

2. Strip upstream CSP headers

Pick one owner for the policy.

3. Build policy from observed traffic

Use browser reports, network traces, and actual templates.

4. Avoid unsafe-inline for scripts

If you need inline scripts, use nonces properly.

5. Be honest about styles

A lot of teams keep style-src 'unsafe-inline' longer than they want. Fine. Just treat it as debt.

6. Include WebSockets explicitly

If your frontend uses wss://, connect-src needs it. People miss this constantly.

7. Lock the obvious directives

These are cheap wins:

  • object-src 'none'
  • frame-ancestors 'none'
  • base-uri 'self'
  • form-action 'self'

Final before/after snapshot

Before

Cheap, simple, and broken:

add_header Content-Security-Policy "
    default-src 'self';
    script-src 'self';
    style-src 'self';
    img-src 'self';
    connect-src 'self';
    object-src 'none';
" always;

After

Messier, but production-grade:

add_header Content-Security-Policy "
    default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
    script-src 'self' 'nonce-$csp_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.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';
" always;

That’s the real story with CSP on an Nginx reverse proxy. The hard part isn’t writing the header. The hard part is making the header tell the truth about your application.