Stripe Elements is one of those integrations that looks simple until CSP gets involved. The moment you lock down script-src, frame-src, and connect-src, your payment form can go from “works great” to “card field is blank and nobody knows why.”

I’ve seen teams waste hours debugging this because Stripe is doing exactly what it should do: loading payment UI in isolated iframes, talking to Stripe APIs, and sometimes pulling in supporting assets from Stripe-controlled domains. CSP needs to allow that behavior without turning your policy into a giant wildcard mess.

Here’s the practical comparison guide I’d want on hand before shipping Stripe Elements on a production site.

The core problem with Stripe Elements and CSP

Stripe Elements is not just a script include. It usually needs:

  • Stripe JavaScript from js.stripe.com
  • embedded frames for card fields and payment UI
  • network calls to Stripe APIs
  • sometimes styles and images from Stripe domains depending on the flow

That means a strict CSP that only allows 'self' will break it.

A typical locked-down baseline might look like the real header from headertest.com:

content-security-policy:
  default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
  script-src 'self' 'nonce-ZjVlMDhjNjItN2MwZC00MzgwLTg4ZWItOGYzOTBhNGQ5MGNi' '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 a decent strict policy for analytics and consent tooling. It will also break Stripe Elements immediately because Stripe isn’t allowed in script-src, frame-src, or connect-src.

The three common CSP approaches

When teams add Stripe Elements, they usually end up in one of these buckets.

Option 1: Add Stripe domains directly to your existing allowlists

This is the most common approach, and usually the best starting point.

Example

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

You may also need additional Stripe endpoints depending on product usage and region, but this is the usual shape for Elements.

Pros

  • Easy to understand
  • Works well with an already strict policy
  • Keeps Stripe permissions scoped to only the directives that need them
  • Good fit for teams already using nonces and strict-dynamic

Cons

  • You need to maintain the domain list as your Stripe integration changes
  • It’s easy to miss one directive and end up with flaky failures
  • Different Stripe products can need slightly different allowances

My take

This is the default choice I’d recommend. It’s explicit, reviewable, and doesn’t blow a hole in your policy. If something breaks, you can reason about it.

Option 2: Loosen CSP with broader wildcards for Stripe

Some teams go with a broad policy because they want fewer support tickets during rollout.

Example

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'unsafe-inline' https://*.stripe.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  connect-src 'self' https://*.stripe.com;
  frame-src 'self' https://*.stripe.com;
  object-src 'none';
  base-uri 'self';

Pros

  • Fastest route to “it works”
  • More resilient to minor Stripe infrastructure changes
  • Lower chance of obscure integration breakage

Cons

  • Too permissive for most production environments
  • Wildcards make reviews harder
  • 'unsafe-inline' in script-src is a bad trade unless you truly have no alternative
  • Broad *.stripe.com allowances may permit more than your app actually needs

My take

I avoid this unless I’m temporarily debugging or dealing with a legacy app that I can’t clean up yet. Broad wildcards have a habit of becoming permanent. Once that happens, your CSP stops being a real control and becomes security theater.

Option 3: Separate CSP by route or page type

This is the mature option for apps with different risk profiles across pages.

Your marketing pages don’t need Stripe. Your checkout page does. So don’t give every page Stripe permissions.

Example

For most pages:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{{ .CSPNonce }}' 'strict-dynamic';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  connect-src 'self';
  frame-src 'self';
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';

For checkout pages:

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

Pros

  • Best least-privilege model
  • Keeps non-payment pages cleaner and safer
  • Easier to audit what payment pages are allowed to do

Cons

  • More deployment complexity
  • Requires your framework or CDN to support route-level headers cleanly
  • Can be annoying in SPAs if checkout is rendered client-side under one shell

My take

If you can do route-specific CSP, do it. This is the cleanest production setup. I especially like it for ecommerce and SaaS billing flows where the checkout path is well defined.

Which directives usually matter for Stripe Elements

These are the ones that trip people up most often.

script-src

You’ll need Stripe’s loader script, usually from:

https://js.stripe.com

If you use nonces and strict-dynamic, keep them. Don’t throw away a good setup just because you’re adding Stripe.

If you want a refresher on how script-src works, the directive reference at https://csp-guide.com is useful.

frame-src

Stripe Elements relies heavily on frames. If card fields don’t render, this is one of the first places I look.

Typical values include:

https://js.stripe.com
https://hooks.stripe.com

connect-src

Stripe needs to talk to its backend APIs.

Typical values include:

https://api.stripe.com
https://r.stripe.com

Depending on your implementation, reporting, and fraud tooling, the exact endpoints can vary.

img-src

Usually https: is enough here if your site already permits remote images broadly. If you run a very strict image policy, Stripe-related assets may need to be allowed explicitly.

style-src

Stripe Elements itself is usually less of a style-src headache than many UI libraries, but your own checkout code may still rely on inline styles or framework-generated style tags. Don’t blame Stripe for your CSS pipeline problems.

Adapting the headertest.com policy for Stripe

Starting from the real header, here’s a practical version with Stripe added while preserving the original structure:

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

That’s the kind of change I’d make first before widening anything further.

Sample Stripe Elements page

Here’s a stripped-down example that matches the CSP shape above.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Checkout</title>
  <script nonce="{{ .CSPNonce }}" src="https://js.stripe.com/v3/"></script>
</head>
<body>
  <form id="payment-form">
    <div id="card-element"></div>
    <button type="submit">Pay</button>
  </form>

  <script nonce="{{ .CSPNonce }}">
    const stripe = Stripe('pk_test_12345');
    const elements = stripe.elements();
    const card = elements.create('card');
    card.mount('#card-element');

    document
      .getElementById('payment-form')
      .addEventListener('submit', async (e) => {
        e.preventDefault();
        // Confirm payment with your server/client flow here
      });
  </script>
</body>
</html>

Common mistakes

Forgetting frame-src

This is probably the number one issue. Developers allow js.stripe.com in script-src and assume they’re done. They’re not.

Adding Stripe to default-src and calling it a day

Don’t rely on default-src as a catch-all if you already use specific directives. Once script-src, frame-src, or connect-src are present, those explicit directives take over.

Dropping strict-dynamic because Stripe broke

That’s usually the wrong fix. Keep your nonce-based model if you already have it.

Allowing all of https:

I get the temptation, especially under launch pressure. It solves the immediate breakage and creates a future audit headache.

If you want the short version:

  • Use route-level CSP if your app can support it
  • Keep nonces and strict-dynamic
  • Add only the Stripe domains required for script-src, connect-src, and frame-src
  • Start narrow, then expand only if real CSP violation reports show a missing dependency

For most Stripe Elements integrations, this is the sweet spot:

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

If you need the exact Stripe requirements for your payment flow, check the official docs at https://docs.stripe.com. For CSP directive behavior, https://csp-guide.com is handy when you need to sanity-check how a directive actually applies.

Stripe Elements and CSP can absolutely coexist. You just need to be deliberate about what Stripe needs, and resist the usual urge to “fix” it with a wildcard.