PayPal checkout button blank in production: Content-Security-Policy blocked the SDK
Our PayPal checkout button rendered in development and staging but was an empty box in production. A new Content-Security-Policy set in Nginx blocked the PayPal SDK script, its iframes and its API calls. We fixed it per directive, with a nonce and a report-only rollout.
On a Monday morning our support inbox for an online course shop had six messages in an hour, all saying roughly the same thing: "there is no way to pay". The checkout page loaded, the order summary was correct, but where the PayPal button should have been there was an empty white box. Paid orders had dropped to zero since Friday evening's deploy. In development and on staging the button rendered normally.
Opening production checkout in Chrome with DevTools made the cause obvious in seconds. The console was red:
Refused to load the script 'https://www.paypal.com/sdk/js?client-id=AXxx...¤cy=EUR' because it
violates the following Content Security Policy directive: "script-src 'self'". Note that
'script-src-elem' was not explicitly set, so 'script-src' is used as a fallback.
Friday's deploy had included a security hardening task: a Content-Security-Policy header added in
the production Nginx config.
Reproducing it
Production reproduced every time. To reproduce locally we copied the header into the dev Nginx config, because our development stack had no CSP at all, which is exactly why nobody saw it before release:
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-src 'self'; form-action 'self'; base-uri 'self'; object-src 'none'" always;
With that header, the dev checkout showed the same empty box and the same console error.
What we ruled out
- PayPal client ID or environment mismatch. A wrong client ID produces an error from the SDK
itself after it loads. Here the script never loaded; the Network panel showed the request as
(blocked:csp). - An ad blocker. The button was blank in a clean guest profile and on a phone.
- Nginx caching an old checkout page. The HTML was current; only the script was blocked.
Root cause
The new policy only allowed resources from our own origin. The PayPal JavaScript SDK needs more than
a script tag: it loads its script from www.paypal.com, renders the buttons inside iframes served
from PayPal, calls PayPal endpoints with fetch, loads images from paypalobjects.com, and injects
small inline elements into our page. Every one of those is covered by a different CSP directive, and
the policy blocked all of them. The first violation hid the others: once script-src blocks the SDK,
it never gets far enough to try the frames or the API calls.
What is happening under the hood
A Content-Security-Policy tells the browser which origins each type of resource may come from. Each
directive covers one type, and default-src is the fallback for fetch directives you do not set:
| Directive | Covers | What the PayPal SDK needed |
|---|---|---|
script-src |
Script elements and inline script | The SDK script from PayPal, plus its inline elements via nonce |
frame-src |
Iframes the page embeds | The button and checkout iframes from PayPal |
connect-src |
fetch, XHR, WebSocket, sendBeacon |
Calls to PayPal endpoints from the SDK |
img-src |
Images | Logos and button assets from PayPal's static host |
style-src |
Stylesheets and inline styles | Styles the SDK injects |
form-action |
Where forms may submit, including redirects | Our redirect-based fallback to PayPal |
The browser enforces the policy independently per resource and logs one console message per blocked request, naming the directive that blocked it. That message is the best debugging tool you have, because it tells you both the host and the directive to change.
PayPal's own help article on button troubleshooting lists *.paypal.com and *.paypalobjects.com as
the domains to allow, across script-src, style-src, connect-src, frame-src and img-src. Its
example policy also includes 'unsafe-inline' for scripts and styles. The SDK offers a better option:
a data-csp-nonce attribute on its script tag, so its inline elements can carry our per-response
nonce instead.
Environment
| Component | Version / value |
|---|---|
| App | Laravel 11 on PHP 8.3-FPM |
| Web server | Nginx 1.26, CSP set with add_header in the production config only |
| Payment | PayPal JavaScript SDK loaded from https://www.paypal.com/sdk/js |
| Browsers seen | Chrome 133, Firefox 135, Safari 18 |
| Dev and staging | No CSP header at all |
Minimal reproduction
<!-- checkout.blade.php (excerpt) -->
<div id="paypal-button-container"></div>
<script src="https://www.paypal.com/sdk/js?client-id={{ $clientId }}¤cy=EUR"></script>
<script src="{{ Vite::asset('resources/js/checkout.js') }}"></script>
Serve that page with script-src 'self' and the container stays empty.
Who is affected
Any site that adds a CSP after third-party widgets are already integrated: payment SDKs, maps, captcha, chat widgets, analytics, video embeds. The risk is highest when the policy is only set in production, because nothing in development or tests exercises it.
The fix
We rebuilt the policy directive by directive, and rolled it out in report-only mode first.
1. Generate a nonce per response. Nginx exposes $request_id, a value generated from 16 random
bytes for every request. We use it as the nonce in the header and pass it to PHP so the template can
print the same value:
# production server block
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param CSP_NONCE $request_id;
}
2. Allow PayPal's documented hosts, per directive, plus the nonce. We keep each directive on its own variable so a reviewer can see exactly what changed:
set $csp_script "script-src 'self' 'nonce-$request_id' https://*.paypal.com https://*.paypalobjects.com";
set $csp_style "style-src 'self' 'nonce-$request_id' https://*.paypal.com";
set $csp_img "img-src 'self' data: https://*.paypal.com https://*.paypalobjects.com";
set $csp_connect "connect-src 'self' https://*.paypal.com";
set $csp_frame "frame-src 'self' https://*.paypal.com";
set $csp_form "form-action 'self' https://*.paypal.com";
add_header Content-Security-Policy "default-src 'self'; $csp_script; $csp_style; $csp_img; $csp_connect; $csp_frame; $csp_form; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; report-uri /csp-report; report-to csp" always;
add_header Reporting-Endpoints 'csp="https://shop.example.com/csp-report"' always;
form-action matters because our fallback for browsers that block popups posts to our server, which
then redirects to PayPal's approval page; browsers apply form-action to that redirect too.
3. Pass the nonce to the SDK. The SDK reads data-csp-nonce and applies it to what it injects;
our own script tag gets the standard nonce attribute:
<script src="https://www.paypal.com/sdk/js?client-id={{ $clientId }}¤cy=EUR"
nonce="{{ request()->server('CSP_NONCE') }}"
data-csp-nonce="{{ request()->server('CSP_NONCE') }}"></script>
4. Avoid the add_header inheritance trap. In Nginx, a location that defines any add_header
does not inherit headers from the server level. Two of our locations set Cache-Control and silently
dropped the CSP header. We moved shared headers into a snippet included in every location that sets
its own headers.
5. Validate and reload.
sudo nginx -t && sudo systemctl reload nginx
We did not add 'unsafe-eval', and we kept 'unsafe-inline' out of script-src. Adding either would
have made the button work immediately and made the policy close to useless against injected script.
Alternatives and trade-offs
- Host allowlist only, with
'unsafe-inline'. This is PayPal's simplest documented example and works, but any XSS on the checkout page could run inline script. - Nonce plus
'strict-dynamic'. Lets nonced scripts load further scripts without listing hosts. It is stronger and less maintenance, but needs every script tag nonced and some care with older browsers. We plan to move there once all our own pages are nonced. - Policy in the app instead of Nginx. Easier per-page policies and nonces, but we wanted one place for security headers across the PHP app and static pages.
HTML pages that carry a nonce must not be cached and served to other users, or the nonce stops being
secret. Our checkout pages are Cache-Control: no-store.
How to test the fix
To restore payments on Monday we removed the enforced header within 20 minutes. Then we shipped the
new policy as Content-Security-Policy-Report-Only for 48 hours, so nothing was blocked while we
collected every violation real browsers produced. Then, locally and in staging, with the same config as production:
curl -sI https://staging-shop.example.com/checkout | grep -i '^content-security-policy'
npx playwright test checkout-csp.spec.ts
The Playwright test fails on any console message containing "Content Security Policy" and asserts that the PayPal iframe appears:
test('checkout renders PayPal with no CSP violations', async ({ page }) => {
const violations: string[] = [];
page.on('console', (msg) => {
if (msg.text().includes('Content Security Policy')) violations.push(msg.text());
});
await page.goto('/checkout?course=demo');
await expect(page.frameLocator('#paypal-button-container iframe').first().locator('body')).toBeVisible();
expect(violations).toEqual([]);
});
Confirming in production
- Paid orders resumed within minutes of removing the original header, and the weekend gap was recovered partly by an email to customers whose carts were abandoned at the payment step.
- During the 48-hour report-only window, the
/csp-reportendpoint received 1,142 reports. After filtering out browser extensions (chrome-extension://and similar sources), every remaining one matched a host already on our list. - After switching to enforcement, reports from the checkout page dropped to a few per day, all from extensions.
Regression guard
- Dev and staging now load the same CSP snippet as production, so a blocked resource shows up before release.
- A CI step lists external hosts referenced in templates and scripts and fails if a host is not in an allowlist file that mirrors the CSP variables:
grep -rhoE "https://[a-zA-Z0-9.*-]+" resources/views resources/js | sort -u > /tmp/hosts.txt
comm -23 /tmp/hosts.txt ops/csp-allowed-hosts.txt | tee /tmp/new-hosts.txt
test ! -s /tmp/new-hosts.txt || { echo "New external hosts need CSP review"; exit 1; }
- The Nginx config is validated in CI with
nginx -tinside the officialnginxcontainer image. - The Playwright CSP test runs against staging on every deploy.
Security implications
The original header was the right idea; deploying it untested took down payments. The fixed policy
keeps most of the protection: scripts only from our origin, PayPal's documented hosts and nonced
elements, no inline script without the nonce, no eval, and forms only to our origin or PayPal. The
report endpoint accepts untrusted input from any browser, so it is rate limited, accepts only JSON
bodies under 10 KB and never renders reports as HTML in our admin tools. Wildcards such as
*.paypal.com trust every subdomain of that provider; that is the trade-off PayPal documents, and we
accepted it instead of pinning hostnames that the SDK may change.
Prevention checklist
- Run the same CSP in development, staging and production.
- Roll out any new or tightened policy with
Content-Security-Policy-Report-Onlyfirst. - Add third-party hosts per directive from the provider's documentation, not by trial and error.
- Prefer nonces over
'unsafe-inline', and never add'unsafe-eval'to make a widget work. - Fail CI when templates reference a host the policy does not allow.
References
- MDN: Content-Security-Policy — https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy
- MDN: Content-Security-Policy-Report-Only — https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy-Report-Only
- W3C: Content Security Policy Level 3 — https://www.w3.org/TR/CSP3/
- PayPal JavaScript SDK script configuration (
data-csp-nonce) — https://developer.paypal.com/sdk/js/configuration/ - PayPal help: pay link and button troubleshooting (CSP domains) — https://www.paypal.com/us/cshelp/article/help1137
- Nginx:
add_header— https://nginx.org/en/docs/http/ngx_http_headers_module.html - Nginx:
$request_idvariable — https://nginx.org/en/docs/http/ngx_http_core_module.html#var_request_id
Add a label
Embed this post
<iframe src="https://hibug.me/csp-blocked-payment-sdk-production" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.