Teams love design systems because they make UI feel consistent. Security teams usually get handed the bill later.

That’s exactly where CSP gets painful in a Gestalt-style frontend: lots of reusable components, analytics hooks, consent tooling, embedded assets, and a build pipeline that mixes app code with third-party scripts. If you’re working on a Pinterest-like stack using Gestalt components, you can’t treat Content Security Policy as a checkbox. You need a policy that survives real product code.

I’m going to use a real CSP header observed on headertest.com as the “after” state, then show what the “before” usually looks like in teams that haven’t cleaned things up yet.

The real-world target

Here’s the real CSP header we’re working from:

Content-Security-Policy:
  default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  script-src 'self' 'nonce-MmE1NGUzOWYtZGJiZC00MjIwLTlmMTktYWQ4NjczMDVmNTAy' '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 a pretty believable production CSP. It has the fingerprints of a modern frontend:

  • a nonce on scripts
  • strict-dynamic
  • analytics and tag manager
  • consent tooling
  • websocket and API endpoints
  • object-src 'none'
  • clickjacking protection via frame-ancestors 'none'

Also, it still has a compromise: style-src 'unsafe-inline'. I’ve shipped policies like this myself. Sometimes you harden scripts first because that’s where the real XSS blast radius lives.

The “before” state: what teams actually deploy

If I had to guess what the application looked like before this policy, it was probably something like this:

Content-Security-Policy:
  default-src * data: blob: 'unsafe-inline' 'unsafe-eval';
  script-src * data: blob: 'unsafe-inline' 'unsafe-eval';
  style-src * data: blob: 'unsafe-inline';
  img-src * data: blob:;
  connect-src *;
  frame-src *;

Or maybe there was no CSP at all.

That kind of policy is common when a component-heavy app grows fast. Gestalt itself isn’t the problem. The problem is how teams use it:

  • inline bootstrapping scripts in templates
  • dynamic script injection for analytics
  • style attributes sprinkled across components
  • third-party SDKs added by marketing
  • ad hoc embeds that nobody inventories
  • legacy code that still depends on eval through old tooling

A design system encourages reuse, but it also spreads bad patterns efficiently. One unsafe inline snippet in a shared shell ends up on every page.

Why the after-policy is better

The biggest jump in maturity here is script-src.

script-src 'self' 'nonce-MmE1NGUzOWYtZGJiZC00MjIwLTlmMTktYWQ4NjczMDVmNTAy' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;

This says:

  • scripts from your own origin are allowed
  • inline scripts need the correct nonce
  • trusted nonce-bearing scripts can load more scripts because of strict-dynamic
  • a few third-party script origins are explicitly allowed

That is a huge step up from unsafe-inline.

If you want the directive-by-directive semantics, the official reference is the MDN and CSP spec docs, and for a practical explainer I often point people to https://csp-guide.com.

The likely migration path

Most teams don’t jump straight to this. They move in stages.

Stage 1: inventory what the app loads

You can’t write a useful CSP from memory. For a Gestalt-based app, I’d start by listing:

  • first-party JS bundles
  • analytics and tag manager scripts
  • consent manager assets
  • image/CDN sources
  • API and websocket endpoints
  • iframes
  • fonts
  • form posts

This usually reveals a mess. Product engineers know the app origin. They often don’t know the consent popup is pulling assets from three different hostnames.

Stage 2: stop relying on inline scripts

Here’s the classic before template:

<script>
  window.__BOOTSTRAP__ = {
    userId: "123",
    flags: { newNav: true }
  };
</script>
<script src="/static/app.js"></script>

That forces you toward unsafe-inline unless you add a nonce.

A safer version:

<script nonce="{{.CSPNonce}}">
  window.__BOOTSTRAP__ = {
    userId: "123",
    flags: { newNav: true }
  };
</script>
<script nonce="{{.CSPNonce}}" src="/static/app.js"></script>

And the header:

Content-Security-Policy:
  script-src 'self' 'nonce-{{nonce}}' 'strict-dynamic';
  object-src 'none';
  base-uri 'self';

If your server renders HTML, generate a fresh nonce per response. Don’t reuse one across requests. I’ve seen teams cache the HTML shell with a fixed nonce and completely gut the protection.

A simple Express example:

import crypto from "node:crypto";
import express from "express";

const app = express();

app.use((req, res, next) => {
  res.locals.cspNonce = crypto.randomBytes(16).toString("base64");
  next();
});

app.use((req, res, next) => {
  const nonce = res.locals.cspNonce;
  res.setHeader(
    "Content-Security-Policy",
    [
      `default-src 'self'`,
      `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.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'`,
    ].join("; ")
  );
  next();
});

Then in your server-rendered page:

<script nonce="{{cspNonce}}" src="/static/runtime.js"></script>
<script nonce="{{cspNonce}}" src="/static/app.js"></script>

Gestalt-specific friction points

With component libraries, the hard parts are usually not the obvious script tags. It’s the glue code.

1. Inline styles and style props

A lot of design systems encourage style props or CSS-in-JS. Some implementations inject <style> tags or rely on inline style attributes. That pushes teams toward:

style-src 'self' 'unsafe-inline'

Which is exactly what the real header still does.

I wouldn’t pretend this is perfect. It isn’t. But I also wouldn’t block a production rollout waiting for style cleanup if script injection is the bigger risk. My usual order is:

  1. lock down script-src
  2. kill object-src
  3. set base-uri
  4. restrict form-action
  5. deal with styles after the app is stable

Marketing tooling is where nice CSPs go to die.

From the real policy:

https://www.googletagmanager.com
https://*.cookiebot.com
https://*.google-analytics.com
https://consent.cookiebot.com
https://consentcdn.cookiebot.com

That tells me the team actually mapped what the vendor stack needs instead of throwing https: everywhere. Good. You should do the same.

3. Real-time endpoints

This one is easy to miss:

connect-src 'self' https://api.headertest.com https://tallycdn.com https://or.headertest.com wss://or.headertest.com ...

If your Gestalt app has live updates, notifications, or observability streams, don’t forget wss: endpoints. Teams often lock down XHR/fetch and then wonder why realtime silently breaks.

Before and after: a practical comparison

Before

Content-Security-Policy:
  default-src * data: blob: 'unsafe-inline' 'unsafe-eval';
  script-src * data: blob: 'unsafe-inline' 'unsafe-eval';
  style-src * data: blob: 'unsafe-inline';
  img-src * data: blob:;
  connect-src *;
  frame-src *;

Problems:

  • any injected inline script runs
  • eval-style sinks are available
  • any third-party host is fair game
  • clickjacking protections are missing
  • plugins and legacy embedded content are still allowed unless blocked elsewhere
  • incident response is harder because the policy tells you nothing about intended behavior

After

Content-Security-Policy:
  default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  script-src 'self' 'nonce-{per-request-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';

What improved:

  • inline scripts require a nonce
  • script trust is explicit
  • third-party access is narrowed to known vendors
  • forms can only post back to self
  • <object>, <embed>, and similar legacy attack surface is gone
  • framing is blocked

What I’d improve next

Even a decent CSP has room to tighten.

First target: remove unsafe-inline from style-src if your styling stack allows it. If your Gestalt usage or CSS-in-JS setup makes that impossible right now, at least treat it as technical debt, not “good enough forever.”

Second target: add reporting during rollout. Use Content-Security-Policy-Report-Only before enforcing major changes so you can catch breakage from hidden dependencies.

Third target: review whether default-src should carry third-party hosts at all. I prefer being more explicit in directive-specific allowlists and keeping default-src 'self' when possible. Broad defaults can hide accidental dependencies.

The main lesson

A good CSP for a Gestalt-based app isn’t about making the header look clever. It’s about forcing the frontend to behave like a system with known dependencies.

The real header here shows a team that did the hard part: they enumerated what the app actually needs, moved scripts onto nonces, allowed specific vendors, and shut off old attack surface. They didn’t get to perfect. Almost nobody does. But they got to defensible.

That’s the bar I’d aim for in a Pinterest-style frontend: not theoretical purity, but a policy that blocks common XSS paths without making the app impossible to ship.