When people talk about Content Security Policy, they usually picture a single app with a single header set by a single server. That’s not how most production systems look anymore.
A real microservices setup is messy. You have an edge proxy, maybe an API gateway, a frontend service doing SSR, a couple of backend APIs, static assets on a CDN, analytics scripts that marketing swears are non-negotiable, and at least one service that still injects inline JavaScript because “that’s how the template engine works.”
That’s exactly where CSP gets painful.
I’ve seen teams try to solve this by stuffing one giant policy into the gateway and calling it done. That usually ends with broken pages, duplicated headers, or a policy so permissive it barely counts as security. The fix is not “more directives.” The fix is ownership and architecture.
The setup
Here’s the kind of stack I’m talking about:
- Edge: NGINX or cloud load balancer
- Frontend service: server-side rendered app
- API services: separate services under
/api/* - Static asset service/CDN
- Third-party integrations:
- Google Tag Manager
- Google Analytics
- Cookiebot
- WebSocket endpoint for real-time updates
A production CSP from a real site like headertest.com gives a solid reference point for what a modern policy can look like:
content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-ZmVkMjczYTAtYmJhMS00ODIyLTgwY2ItNmJhMjUzNGRkMDJm' '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'
That header tells a real story. It’s not theoretical. It has analytics, consent tooling, API calls, WebSockets, and a nonce-based script policy. That’s the kind of thing you actually deploy.
Before: one CSP at the edge, chaos everywhere
The team I worked with started with this idea:
- Put CSP in the ingress proxy
- Apply it to every response
- Let downstream services do whatever they want
The header looked like this:
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline'; img-src * data:; connect-src *; frame-src *;
This is the classic “we technically have CSP” policy. It checked a compliance box, but it was weak in all the places that matter:
'unsafe-inline'allowed injected inline scripts'unsafe-eval'kept old bundles working but weakened script protectionsimg-src *,connect-src *,frame-src *were far too broad- Every service got the same policy, even when they had different needs
Worse, some services also set their own CSP. Browsers can enforce multiple CSP headers, and the effective policy becomes more restrictive in weird ways. One route would work, another would fail, and debugging felt like archaeology.
The real problem in microservices
CSP in microservices breaks down because different layers want control:
- Gateway wants a global baseline
- Frontend service needs nonces for inline SSR bootstrapping
- Static asset service doesn’t need the same policy as HTML pages
- API services usually don’t need CSP at all because they return JSON, not HTML
- Embedded apps might require different
frame-srcorframe-ancestors
If you don’t split these responsibilities, you end up with either duplicated policy or a lowest-common-denominator mess.
After: baseline at the edge, HTML-specific CSP in the frontend
The rollout that worked used two rules:
- Only HTML responses get CSP
- The frontend service owns script/style rules because it knows the page
The edge still enforced a small baseline for all HTML:
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Content-Type-Options "nosniff" always;
But CSP itself moved to the frontend app, where we could generate a nonce per request.
Step 1: generate a nonce in the SSR service
Node.js example:
import crypto from "node:crypto";
function generateNonce() {
return crypto.randomBytes(16).toString("base64");
}
app.use((req, res, next) => {
res.locals.cspNonce = generateNonce();
next();
});
Then use it in the template:
<script nonce="{{cspNonce}}">
window.__BOOTSTRAP__ = {{ bootstrapJson }};
</script>
<script nonce="{{cspNonce}}" src="/static/app.js"></script>
That let us remove 'unsafe-inline' from script-src, which is one of the biggest security wins you can get from CSP.
Step 2: build the policy from real dependencies
Here’s the “after” policy pattern, adapted for a microservices frontend with analytics, consent tooling, API calls, and WebSockets:
function buildCsp(nonce) {
return [
"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.example.internal wss://realtime.example.internal 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'",
"report-uri /csp-report"
].join("; ");
}
app.use((req, res, next) => {
const nonce = res.locals.cspNonce;
res.setHeader("Content-Security-Policy", buildCsp(nonce));
next();
});
A few opinions here:
- I’m fine with
'unsafe-inline'instyle-srcas a temporary compromise. A lot of third-party tools still force your hand there. - I would fight hard to avoid it in
script-src. object-src 'none',base-uri 'self', andform-action 'self'should be there unless you have a very specific reason not to.frame-ancestors 'none'is fantastic if your app should never be embedded.
If you want ready-made patterns for common app setups, https://csp-examples.com is useful for comparing directive combinations.
Step 3: stop sending CSP from API services
This was a surprisingly useful cleanup.
Before:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Security-Policy: default-src 'self'; script-src 'self'
After:
HTTP/1.1 200 OK
Content-Type: application/json
CSP protects documents loaded by the browser, mostly HTML. JSON APIs don’t benefit from carrying their own page policy. In a microservices environment, removing unnecessary headers from non-HTML services reduces confusion fast.
Step 4: use Report-Only before enforcement
The first enforced version always breaks something. Always.
So we rolled out Content-Security-Policy-Report-Only first:
app.use((req, res, next) => {
const nonce = res.locals.cspNonce;
res.setHeader("Content-Security-Policy-Report-Only", buildCsp(nonce));
next();
});
And added a report collector endpoint:
app.post("/csp-report", express.json({ type: ["application/csp-report", "application/json"] }), (req, res) => {
console.log("CSP violation:", JSON.stringify(req.body));
res.sendStatus(204);
});
This flushed out three real issues:
- A legacy inline script in a shared header partial
- A forgotten analytics call to a region-specific subdomain
- WebSocket connections missing from
connect-src
That’s normal. CSP is less about writing the perfect header on day one and more about finding what your app actually does.
Before and after: policy quality
Before
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline'; img-src * data:; connect-src *; frame-src *;
After
Content-Security-Policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-<per-request>' '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.example.internal wss://realtime.example.internal 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'
That’s a real upgrade:
- no
'unsafe-eval' - no wildcard
connect-src * - no wildcard
frame-src * - inline scripts locked behind request-specific nonces
- third-party access explicitly documented in the header itself
What actually made it work
The technical part mattered, but the process mattered more.
1. One service owned the HTML policy
If multiple services set CSP, things get ugly. Pick one owner.
2. Dependencies were treated like code
Every new third-party script required a CSP review. No silent additions.
3. The policy was environment-aware
Staging had extra sources for local tooling and preview assets. Production stayed tighter.
4. Teams stopped thinking of CSP as a static string
In a microservices platform, CSP is generated configuration. Treat it that way.
A practical rollout model
If you’re doing this on a real developer team, I’d use this order:
- Inventory all HTML-rendering services
- Remove CSP from JSON/API-only services
- Pick one HTML owner per app
- Add nonces in SSR/templates
- Start with
Report-Only - Fix violations
- Enforce
- Review every third-party integration change
For the directive details and browser behavior, the official docs are still the source of truth: MDN Content-Security-Policy.
The biggest mindset shift is this: in microservices, CSP is not a perimeter control. It’s part of your rendering pipeline.
Once the frontend service owns the policy, the gateway stops guessing, the APIs stop sending nonsense headers, and the browser gets one clear set of rules. That’s when CSP goes from “security theater” to something that actually blocks bugs.