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
-
Google Tag Manager didn’t load
script-src 'self'blockedwww.googletagmanager.com
-
Cookie consent UI broke
- Cookiebot needed scripts, frames, and styles from its own domains
-
Analytics stopped sending data
connect-src 'self'blocked requests to Google Analytics
-
Images from external HTTPS URLs failed
- A bunch of CMS content referenced external assets
-
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 downframe-ancestors 'none'blocks clickjackingbase-uri 'self'andform-action 'self'are explicitly setconnect-srcincludes both HTTPS and WebSocket targetsscript-srcuses a nonce andstrict-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-srccan 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.