Content Security Policy for E-commerce Checkouts: A Working Example
A Content Security Policy (CSP) is the second pillar of PCI 6.4.3 compliance, alongside SRI. Where SRI guarantees a specific script wasn’t tampered with, CSP guarantees the browser won’t load any script from a domain you didn’t authorize. They’re complementary.
This article gives you a working CSP for a typical SMB checkout, explains every directive, and lists the mistakes that look fine in dev but fail in production.
The 60-second version
A CSP is an HTTP response header that tells the browser, in machine-readable form: “only load resources from these specific places, and tell me when something tries to load from anywhere else.”
Set the header on your checkout page. List the domains your scripts, styles, frames, and connections actually need. Anything not on the list gets blocked. The browser POSTs a JSON report to a URL you specify whenever a violation happens.
That’s it conceptually. The complication is in writing the right list — narrow enough to be a real security control, wide enough that your checkout still works.
A working CSP for a WooCommerce + Stripe Elements checkout
Here’s a CSP for a WooCommerce checkout that uses Stripe Elements, Google Analytics 4 (loaded via Google Tag Manager), and a typical recommendation widget:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'unsafe-inline'
https://js.stripe.com
https://www.googletagmanager.com
https://www.google-analytics.com
https://widget.recommend.example.com;
style-src 'self' 'unsafe-inline'
https://fonts.googleapis.com;
font-src 'self'
https://fonts.gstatic.com;
img-src 'self' data:
https://www.google-analytics.com
https://www.googletagmanager.com;
frame-src
https://js.stripe.com
https://hooks.stripe.com;
connect-src 'self'
https://api.stripe.com
https://www.google-analytics.com
https://www.googletagmanager.com;
form-action 'self';
base-uri 'self';
object-src 'none';
frame-ancestors 'none';
upgrade-insecure-requests;
report-to csp-endpoint;
report-uri https://yourstore.com/csp-report;
You’d serve this on the checkout page only — not the entire site, because a homepage that needs to load YouTube embeds and Twitter widgets has a wildly different threat model than a checkout that needs to load three things and a card form.
(For Shopify with Stripe-enabled apps, replace the Stripe domains with whatever your specific app’s responsibility matrix lists. For a non-Stripe processor, swap in their domains.)
Let’s walk through it.
Directive by directive
default-src 'self'
The fallback. If a directive isn’t otherwise specified, only resources from the same origin (self = your domain) are allowed. This is the safety net that catches anything you forgot.
script-src
Where JavaScript can come from.
'self'— scripts hosted on your own domain.'unsafe-inline'— allows inline<script>blocks. You usually want this off, but most WooCommerce themes and plugins emit inline scripts, and removing them is an enormous project. The realistic compromise: keep'unsafe-inline'but use SRI on every external script (where possible) and rely onscript-srcrestrictions on third-party domains.- The list of allowed third-party domains. Each one is a domain that can serve a script if it tries. Domains not on the list are blocked.
style-src
Same logic for CSS. 'unsafe-inline' is similarly often required for compatibility — many CMS themes inject inline style="" attributes.
font-src, img-src
Where fonts and images can come from. data: allows base64-encoded inline images, which most CMS plugins use for tiny icons.
frame-src
Which domains can be embedded in iframes. For Stripe Elements specifically, js.stripe.com and hooks.stripe.com are required — the card input field is an iframe served from one of those.
connect-src
Which domains your scripts can make AJAX/fetch/WebSocket requests to. Stripe’s API endpoint, your analytics ingestion endpoint, and your own domain.
This directive often surprises people because it’s invisible — there’s no <script src=> to look at. But it’s where exfiltration would happen if a skimmer got onto the page. A tight connect-src makes it harder for a skimmer to phone home.
form-action 'self'
Which domains your forms can submit to. Always ‘self’ on a checkout — you don’t want any HTML form on this page submitting to a third-party domain.
base-uri 'self'
Restricts what <base> tags can do. A common evasion technique is for an attacker to inject <base href="https://evil.com/">, which causes all relative URLs on the page to resolve to the attacker’s domain. base-uri 'self' blocks this.
object-src 'none'
Blocks <object>, <embed>, and <applet>. None of those have a legitimate place on a modern checkout. Always 'none'.
frame-ancestors 'none'
Forbids your checkout page from being framed by another page. This stops clickjacking — an attacker can’t put your checkout in a transparent iframe on their malicious page. Also superior to the older X-Frame-Options header.
upgrade-insecure-requests
Forces all HTTP requests on the page to upgrade to HTTPS. Belt-and-suspenders for the rare misconfigured plugin that still tries to load something over HTTP.
report-to and report-uri
The reporting endpoint. report-to is the modern directive (uses the Reporting-Endpoints HTTP header to declare endpoints). report-uri is the legacy version. Set both. Browsers will use one or the other depending on version.
A violation report looks roughly like this:
{
"csp-report": {
"document-uri": "https://yourstore.com/checkout",
"violated-directive": "script-src",
"blocked-uri": "https://unexpected-domain.com/skim.js",
"source-file": "https://yourstore.com/checkout",
"line-number": 142
}
}
When you see a report for a domain you didn’t authorize, that’s the early-warning system that something is wrong on your page.
The four mistakes that turn CSP into security theater
1. 'unsafe-eval' in script-src
eval() and new Function(...) are how a lot of skimmers operate — they bring in obfuscated payload, decode it, and execute via eval. If your CSP allows 'unsafe-eval', skimmers can run.
Some legitimate libraries (a few older charting and templating libs) use eval. Find them, replace them. The presence of 'unsafe-eval' in a payment-page CSP is something a competent QSA will mark.
2. * in script-src
script-src * allows scripts from any HTTPS origin. It’s barely better than no policy at all. If you find yourself adding more domains week by week and considering *, the answer is to install fewer plugins, not weaken the policy.
3. Forgetting report-to/report-uri
A CSP without reporting is a one-way door. The browser blocks unauthorized loads but doesn’t tell you it’s happening. You’d never know a Magecart attack hit your site if your CSP silently blocked the exfiltration but didn’t report.
Always set up reporting. Even a CSV file appended to a Cloudflare Worker is better than nothing.
4. Setting CSP only on the checkout page (without testing)
CSP is the kind of header where a misconfiguration breaks the page silently. You set it, you publish, customers can’t check out, you don’t notice for two days because nobody complains directly.
The standard rollout pattern:
- Deploy as
Content-Security-Policy-Report-Onlyfirst. This header tells the browser to report violations but not block them. Run it for a week. Look at the reports. Find the domains your real users actually load that you forgot to allow. - Add the missing domains to your
script-src/connect-src/etc. - Switch to
Content-Security-Policy(enforcing). Reports keep coming for any further violations, but now the browser actively blocks. - Keep monitoring — vendors push updates, your team installs new plugins, traffic patterns change. The list isn’t stable.
Skipping the report-only phase is how merchants discover their CSP broke their checkout an hour after deploy.
Where to set the header
Three common places, in increasing order of reliability:
WordPress plugin — WP CSP Manager, Headers Security Advanced & HSTS WP, etc. Easy to install, lives in PHP. Risk: the plugin sets the header from PHP’s runtime, which means any other plugin that sends headers earlier wins. Test carefully.
Web server config (Nginx/Apache) — set the header in your virtual host config, conditional on the URL path. More reliable than a plugin because it runs before any PHP code. Best for VPS or self-hosted setups.
CDN / edge (Cloudflare Workers, Fastly, AWS CloudFront) — set the header at the edge, before the request even reaches your origin. Most reliable. Lets you change the policy without touching the origin server. This is where we recommend most merchants put it.
For Cloudflare specifically, a Workers script of about 15 lines can apply the CSP only on the /checkout path and pass through everything else.
How CSP fits into PCI 6.4.3
CSP doesn’t satisfy 6.4.3 by itself — 6.4.3 wants an inventory and integrity proof. CSP is a defense in depth control: even if your inventory is wrong or your SRI gap leaves a script unprotected, CSP can still block exfiltration.
The standard 6.4.3 evidence pack includes:
- The script inventory (per the 6.4.3 article).
- SRI integrity hashes on every script that supports them (SRI guide).
- The CSP header you serve (this article).
- Reports from
report-toshowing the policy is active and capturing violations.
A QSA who sees all four is satisfied. Skip any one and you’ve left a gap.
Run a free scan of your checkout — the report tells you both your script inventory and whether you currently have a CSP header. Most merchants find out they don’t.
Run a free PCI 6.4.3 scan of your checkout page. Get the script inventory and a one-page PDF report. Try it now →