Web Development Bug Report

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.

Daniel Tran Daniel Tran @fun_dev · March 04, 2025 · 10 min read · 10 Views
Banner: Content-Security-Policy header blocking a payment SDK script and iframe on a checkout page

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...&currency=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 }}&currency=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 }}&currency=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-report endpoint 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 -t inside the official nginx container 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-Only first.
  • 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

Add a label

#nginx #csp #paypal #content-security-policy #payments
Written by
Daniel Tran @fun_dev

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.