I’ve seen this exact failure more than once: marketing drops in a Stripe pricing table, everything looks fine locally, then production CSP quietly blocks it and the page ships half-broken.

The annoying part is that Stripe’s pricing table is simple to embed, but CSP rarely is. If your site already has Google Tag Manager, analytics, consent tooling, and a reasonably locked-down policy, adding one more third-party script can turn into a guessing game fast.

Here’s a real-world case study using a production-style CSP as the starting point.

The setup

A developer had a site with a CSP header similar to this one:

content-security-policy:
  default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  script-src 'self' 'nonce-ODRmZWIyOGUtYjhjMi00ZTZkLWJiNGYtMGMwYzY2NmM1Nzdj' '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’s not a bad baseline. It already avoids the usual disasters:

  • object-src 'none'
  • base-uri 'self'
  • frame-ancestors 'none'
  • nonce-based script-src
  • strict-dynamic

Then Stripe pricing table was added with the standard embed:

<script async src="https://js.stripe.com/v3/pricing-table.js"></script>

<stripe-pricing-table
  pricing-table-id="prctbl_123"
  publishable-key="pk_live_123">
</stripe-pricing-table>

And production immediately broke.

What failed

The browser console showed the classic CSP errors:

Refused to load the script 'https://js.stripe.com/v3/pricing-table.js'
because it violates the following Content Security Policy directive:
"script-src 'self' 'nonce-...' 'strict-dynamic' https://www.googletagmanager.com ..."

Then, after allowing the script, more failures appeared:

Refused to frame 'https://js.stripe.com/' because it violates
the following Content Security Policy directive: "frame-src 'self' https://consentcdn.cookiebot.com"

And depending on how Stripe rendered the table in that session, there could also be blocked network calls:

Refused to connect to 'https://r.stripe.com/...'
because it violates the following Content Security Policy directive: "connect-src ..."

This is the part that trips people up: fixing Stripe pricing table usually isn’t just a script-src change.

Why the original policy blocked Stripe

A few things were happening at once.

1. script-src did not allow Stripe

The policy only allowed:

  • self
  • GTM
  • Cookiebot
  • Google Analytics

No Stripe domain was present.

2. frame-src was too strict for Stripe’s embed model

Stripe pricing tables can render using embedded content that needs framing support. If your frame-src only allows your own origin and Cookiebot, Stripe gets blocked.

3. connect-src may also need Stripe endpoints

Even if the script loads, the component may still need network access for telemetry or content fetches. If you don’t allow that, you end up with partial rendering or weird runtime failures.

4. strict-dynamic changes how host allowlists behave

This one matters a lot. With strict-dynamic, nonce- or hash-trusted scripts become the root of trust. Host-based source expressions may be ignored by supporting browsers in ways people don’t expect. If you rely on a plain external <script src="https://js.stripe.com/..."> tag without a nonce, your host allowlist may not save you.

If you want the details, the official CSP docs are still the source of truth, and https://csp-guide.com has a good practical explanation of strict-dynamic.

The broken version

Here’s the “before” state in simplified form.

HTML

<script async src="https://js.stripe.com/v3/pricing-table.js"></script>

<stripe-pricing-table
  pricing-table-id="prctbl_123"
  publishable-key="pk_live_123">
</stripe-pricing-table>

CSP

Content-Security-Policy:
  default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  script-src 'self' 'nonce-ODRmZWIyOGUtYjhjMi00ZTZkLWJiNGYtMGMwYzY2NmM1Nzdj' '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 policy is sane for the tools already in use. It just doesn’t know Stripe exists.

The fix that actually worked

The cleanest fix was:

  1. Add a nonce to the Stripe script tag.
  2. Allow Stripe in the relevant directives.
  3. Keep the policy tight instead of falling back to broad https: everywhere.

Updated HTML

If you’re using nonces, use them consistently:

<script
  nonce="{{ .CSPNonce }}"
  async
  src="https://js.stripe.com/v3/pricing-table.js"></script>

<stripe-pricing-table
  pricing-table-id="prctbl_123"
  publishable-key="pk_live_123">
</stripe-pricing-table>

If your app generates the nonce server-side, inject the same nonce into every trusted script on the page.

Updated CSP

Content-Security-Policy:
  default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  script-src 'self' 'nonce-ODRmZWIyOGUtYjhjMi00ZTZkLWJiNGYtMGMwYzY2NmM1Nzdj' 'strict-dynamic' https://js.stripe.com 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 https://js.stripe.com https://r.stripe.com;
  frame-src 'self' https://consentcdn.cookiebot.com https://js.stripe.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self' https://checkout.stripe.com;
  object-src 'none';

Why each change mattered

script-src https://js.stripe.com

This is the obvious one, but with a catch: because strict-dynamic is present, I still nonce the Stripe <script> tag. I don’t trust future me, another browser, or some edge rendering path to behave exactly how I expected six months ago.

If you use nonces, use them on the Stripe script too.

frame-src https://js.stripe.com

Without this, the pricing table can fail even after the script loads. A lot of “I added Stripe to script-src and it still doesn’t work” reports come down to missing frame-src.

connect-src https://js.stripe.com https://r.stripe.com

This is the least obvious change. Stripe often needs network access outside the initial script fetch. r.stripe.com is a common one to see in practice.

I’d start narrow, watch the browser CSP violations in report-only mode, then expand only if Stripe clearly needs another official endpoint.

form-action https://checkout.stripe.com

If the pricing table flow sends users into a Stripe-hosted checkout path, form-action 'self' may block that transition. This one depends on your exact integration, but I’ve had better luck being explicit up front instead of waiting for checkout to fail in one browser during a launch.

What I would not do

I would not “fix” this with something like:

script-src 'self' 'unsafe-inline' 'unsafe-eval' https:;
frame-src https:;
connect-src https:;

Yes, it makes the widget work. It also turns your CSP into decorative security theater.

Same for copying random forum answers that dump every Stripe domain ever seen into every directive. That creates policy sprawl fast, and nobody wants to maintain a CSP that reads like a DNS zone file.

A safer rollout path

When I’m changing CSP for third-party widgets, I usually do this in two steps.

Step 1: Report-only

Ship a Content-Security-Policy-Report-Only header first:

Content-Security-Policy-Report-Only:
  script-src 'self' 'nonce-...' 'strict-dynamic' https://js.stripe.com ...;
  frame-src 'self' https://consentcdn.cookiebot.com https://js.stripe.com;
  connect-src 'self' ... https://js.stripe.com https://r.stripe.com;
  form-action 'self' https://checkout.stripe.com;
  object-src 'none';
  base-uri 'self';

Then exercise the pricing table in a real browser, not just a static page load.

Click things. Switch plans. Trigger the checkout path.

Step 2: Enforce

Once the violations stop and the flow works, move the same policy into the enforcing header.

Final before-and-after

Before

  • Stripe script blocked
  • Pricing table missing or half-rendered
  • Checkout transition may fail
  • Developers blame Stripe, but CSP is the real issue

After

  • Stripe script loads with the same nonce model as the rest of the page
  • Frames allowed only where needed
  • Network access scoped to actual Stripe endpoints
  • Checkout flow allowed explicitly
  • Existing CSP posture stays mostly intact

Practical template for a Stripe pricing table

If you already have a mature CSP, this is the shape I’d start with:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM}' 'strict-dynamic' https://js.stripe.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self' https://js.stripe.com https://r.stripe.com;
  frame-src 'self' https://js.stripe.com;
  form-action 'self' https://checkout.stripe.com;
  base-uri 'self';
  frame-ancestors 'none';
  object-src 'none';

And the matching embed:

<script
  nonce="{{ .CSPNonce }}"
  async
  src="https://js.stripe.com/v3/pricing-table.js"></script>

<stripe-pricing-table
  pricing-table-id="prctbl_123"
  publishable-key="pk_live_123">
</stripe-pricing-table>

For official reference, check Stripe’s docs for the pricing table embed and the CSP specification documentation for directive behavior. If you want a practical breakdown of directives like script-src, frame-src, and strict-dynamic, https://csp-guide.com is worth keeping open in another tab.

My general rule: treat Stripe like any other third-party app dependency. Give it exactly the capabilities it needs, no more, and verify the whole user flow under CSP before you call it done. That’s how you keep “secure” from becoming “mysteriously broken in production.”