CSP for PayPal Smart Buttons: Copy-Paste Reference

Table of Contents

PayPal Smart Buttons are one of those integrations that look trivial until CSP gets involved. You paste a script tag, render a button, and suddenly checkout dies because a frame, XHR, or popup got blocked somewhere deep in the flow.

I’ve had to debug this enough times that I now treat PayPal as a “bring a real CSP plan” integration, not a simple third-party script include.

This guide is the practical version: what to allow, what usually breaks, and copy-paste policies you can start from.

The PayPal Smart Buttons script

Most integrations start with this:

<script src="https://www.paypal.com/sdk/js?client-id=YOUR_CLIENT_ID&currency=USD"></script>

Then you render buttons:

<div id="paypal-button-container"></div>

<script>
  paypal.Buttons({
    createOrder() {
      return fetch("/api/paypal/create-order", {
        method: "POST"
      })
      .then(res => res.json())
      .then(order => order.id);
    },
    onApprove(data) {
      return fetch("/api/paypal/capture-order", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ orderID: data.orderID })
      });
    }
  }).render("#paypal-button-container");
</script>

That means your CSP usually needs to handle:

  • loading PayPal’s SDK script
  • loading PayPal-owned subresources
  • embedded frames for the checkout flow
  • API calls back to your app
  • popups or redirects depending on payment method
  • images and styles used by the button UI

Minimum CSP directives to think about

For PayPal Smart Buttons, these directives matter most:

  • script-src
  • frame-src
  • connect-src
  • img-src
  • style-src
  • child-src if you still support older CSP handling
  • frame-ancestors if your own app is embedded somewhere
  • form-action in some redirect-heavy flows

If your site already has a tight CSP with nonces and strict-dynamic, good. If not, PayPal is where weak policies often start creeping in.

Good starting policy for PayPal Smart Buttons

Here’s a baseline policy for a site that serves its own app, calls its own backend, and embeds PayPal Smart Buttons.

Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://www.paypal.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https://www.paypal.com https://www.paypalobjects.com;
  connect-src 'self' https://www.paypal.com https://www.sandbox.paypal.com;
  frame-src https://www.paypal.com https://www.sandbox.paypal.com;
  object-src 'none';
  base-uri 'self';
  form-action 'self' https://www.paypal.com;
  frame-ancestors 'self';

This is intentionally simple. It’s not the final form for every app, but it covers the common Smart Buttons flow.

Stricter version with nonce

If you already use nonces, keep doing that. Don’t punch a hole with 'unsafe-inline' in script-src just because a payment widget showed up.

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-RANDOM_NONCE' https://www.paypal.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https://www.paypal.com https://www.paypalobjects.com;
  connect-src 'self' https://www.paypal.com https://www.sandbox.paypal.com;
  frame-src https://www.paypal.com https://www.sandbox.paypal.com;
  object-src 'none';
  base-uri 'self';
  form-action 'self' https://www.paypal.com;

And your page:

<script nonce="RANDOM_NONCE" src="https://www.paypal.com/sdk/js?client-id=YOUR_CLIENT_ID&currency=USD"></script>

<div id="paypal-button-container"></div>

<script nonce="RANDOM_NONCE">
  paypal.Buttons({
    createOrder() {
      return fetch("/api/paypal/create-order", { method: "POST" })
        .then(r => r.json())
        .then(order => order.id);
    }
  }).render("#paypal-button-container");
</script>

If you use strict-dynamic

This is usually the cleanest setup if your app is already built around nonces.

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-RANDOM_NONCE' 'strict-dynamic' https://www.paypal.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  connect-src 'self' https://www.paypal.com https://www.sandbox.paypal.com;
  frame-src https://www.paypal.com https://www.sandbox.paypal.com;
  object-src 'none';
  base-uri 'self';
  form-action 'self' https://www.paypal.com;

One real-world reference I like is the CSP used by HeaderTest. Their header includes a modern script-src with a nonce and strict-dynamic:

content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-NzAyM2FiNjAtMWM5OC00YTZmLTgzMWYtNjY5ZjJhNWVmMTAx' '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 PayPal policy, obviously, but it’s a solid example of how a modern CSP can stay strict without turning into a maintenance disaster.

Sandbox vs production

A lot of people test in sandbox, lock down CSP, then break production because they only allowed sandbox domains.

During development, I usually allow both:

connect-src 'self' https://www.paypal.com https://www.sandbox.paypal.com;
frame-src https://www.paypal.com https://www.sandbox.paypal.com;
form-action 'self' https://www.paypal.com https://www.sandbox.paypal.com;

If you want environment-specific CSPs, that’s cleaner:

Development / sandbox

Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://www.paypal.com;
  connect-src 'self' https://www.sandbox.paypal.com;
  frame-src https://www.sandbox.paypal.com;
  img-src 'self' data: https://www.paypal.com https://www.paypalobjects.com;
  style-src 'self' 'unsafe-inline';
  object-src 'none';

Production

Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://www.paypal.com;
  connect-src 'self' https://www.paypal.com;
  frame-src https://www.paypal.com;
  img-src 'self' data: https://www.paypal.com https://www.paypalobjects.com;
  style-src 'self' 'unsafe-inline';
  object-src 'none';

Common breakages

1. Button script loads, but button never renders

Usually frame-src or connect-src.

PayPal can load the SDK script fine, then fail later when it tries to create frames or make network requests.

Check the console for messages like:

Refused to frame 'https://www.paypal.com/' because it violates the following Content Security Policy directive: "frame-src 'self'".

Fix:

frame-src 'self' https://www.paypal.com https://www.sandbox.paypal.com;

2. Order creation works, approval fails

Often connect-src is too narrow.

You need both your own backend and PayPal endpoints where applicable:

connect-src 'self' https://www.paypal.com https://www.sandbox.paypal.com;

If your frontend only talks to your own backend and your server talks to PayPal, you may still need PayPal in connect-src because the SDK itself performs client-side requests.

3. Popup or redirect flow gets blocked

Look at form-action and occasionally popup-related browser behavior around frames.

Try:

form-action 'self' https://www.paypal.com https://www.sandbox.paypal.com;

4. Images or logos inside the PayPal UI are missing

Your img-src is too tight.

img-src 'self' data: https://www.paypal.com https://www.paypalobjects.com;

If you already use img-src 'self' data: https:, that’s broader and usually enough.

Example for Express / Node.js

Here’s a copy-paste Express setup with Helmet.

import express from "express";
import helmet from "helmet";

const app = express();

app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'", "https://www.paypal.com"],
        styleSrc: ["'self'", "'unsafe-inline'"],
        imgSrc: ["'self'", "data:", "https://www.paypal.com", "https://www.paypalobjects.com"],
        connectSrc: ["'self'", "https://www.paypal.com", "https://www.sandbox.paypal.com"],
        frameSrc: ["'self'", "https://www.paypal.com", "https://www.sandbox.paypal.com"],
        formAction: ["'self'", "https://www.paypal.com", "https://www.sandbox.paypal.com"],
        objectSrc: ["'none'"],
        baseUri: ["'self'"]
      }
    }
  })
);

app.listen(3000);

Example for Nginx

add_header Content-Security-Policy "
  default-src 'self';
  script-src 'self' https://www.paypal.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https://www.paypal.com https://www.paypalobjects.com;
  connect-src 'self' https://www.paypal.com https://www.sandbox.paypal.com;
  frame-src 'self' https://www.paypal.com https://www.sandbox.paypal.com;
  form-action 'self' https://www.paypal.com https://www.sandbox.paypal.com;
  object-src 'none';
  base-uri 'self';
" always;

Report-Only first. Always.

For payment flows, I strongly prefer shipping a candidate policy in Content-Security-Policy-Report-Only before enforcing it.

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self' https://www.paypal.com;
  connect-src 'self' https://www.paypal.com https://www.sandbox.paypal.com;
  frame-src 'self' https://www.paypal.com https://www.sandbox.paypal.com;
  img-src 'self' data: https://www.paypal.com https://www.paypalobjects.com;
  style-src 'self' 'unsafe-inline';
  object-src 'none';
  report-uri /csp-report;

If you want prebuilt templates for different stacks and CSP styles, csp-examples.com is useful for quick starting points.

My default recommendation

If you want the short version:

  • keep default-src 'self'
  • allow https://www.paypal.com in script-src
  • allow PayPal in frame-src
  • allow PayPal in connect-src
  • allow PayPal and paypalobjects.com in img-src
  • keep object-src 'none'
  • use nonces if your app already supports them
  • deploy with Report-Only before enforcement

Here’s the version I’d start with for most apps:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://www.paypal.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https://www.paypal.com https://www.paypalobjects.com;
  connect-src 'self' https://www.paypal.com https://www.sandbox.paypal.com;
  frame-src 'self' https://www.paypal.com https://www.sandbox.paypal.com;
  form-action 'self' https://www.paypal.com https://www.sandbox.paypal.com;
  base-uri 'self';
  object-src 'none';

Then tighten from there based on what your actual integration uses. That’s the part people skip, and it’s why checkout breaks at 2 AM instead of during staging.