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¤cy=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-srcframe-srcconnect-srcimg-srcstyle-srcchild-srcif you still support older CSP handlingframe-ancestorsif your own app is embedded somewhereform-actionin 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¤cy=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.cominscript-src - allow PayPal in
frame-src - allow PayPal in
connect-src - allow PayPal and
paypalobjects.cominimg-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.