Content Security Policy on WooCommerce sites is where good intentions go to die.

I’ve seen teams turn on a “strict” CSP, feel great for five minutes, then realize checkout is broken, Stripe fields don’t load, product images disappear from the CDN, and marketing starts yelling because GA4 went dark. E-commerce is one of the hardest places to deploy CSP well because a store is never just your code. It’s your theme, plugins, payment providers, tag managers, fraud tools, cookie banners, live chat, analytics, A/B testing, and whatever one-off script someone added during Black Friday.

The good news: the mistakes are pretty predictable.

Mistake #1: Starting with a generic CSP and applying it everywhere

A lot of WooCommerce sites ship one global header and call it done. That sounds tidy, but storefront pages, checkout, account pages, and wp-admin don’t behave the same way.

A homepage might only need your own assets, analytics, and a consent manager. Checkout usually needs payment iframes, tokenization endpoints, fraud detection scripts, and cross-origin XHR. Admin pages often need WordPress and plugin-specific inline behavior that you do not want to blindly allow on the public site.

A real-world CSP often looks like this:

content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-ZWM1ZWFlYmEtOWFlYS00YTcxLTkyMmQtMzNhNDY5YTEzYWNj' '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 already more nuanced than most WooCommerce deployments. Even so, it would still need changes for many stores because payment gateways usually require extra script-src, connect-src, and frame-src entries.

Fix

Use route-aware CSP. At minimum, split policies by:

  • storefront pages
  • cart and checkout
  • account pages
  • wp-admin
  • webhook or API endpoints if relevant

If you’re using a reverse proxy or CDN, generate different CSP headers based on path. If you’re setting headers in WordPress or PHP, branch by request URI.

add_action('send_headers', function () {
    $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

    $base = "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none';";

    if (strpos($path, '/checkout') === 0) {
        header("Content-Security-Policy: $base script-src 'self' 'nonce-" . wp_create_nonce('csp') . "' https://js.stripe.com; connect-src 'self' https://api.stripe.com; frame-src https://js.stripe.com; form-action 'self';");
        return;
    }

    header("Content-Security-Policy: $base script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:;");
});

That example is intentionally simple, but the point stands: checkout is special. Treat it that way.

Mistake #2: Using default-src as a catch-all and assuming it covers everything correctly

People love to stuff hosts into default-src and hope for the best. That’s not how CSP should be designed on a store.

default-src is a fallback. Once you define script-src, img-src, connect-src, frame-src, and friends, those directives take over. If your payment SDK is failing because it opens a frame or sends XHR to an API, adding the domain to default-src won’t fix it.

This is especially common with WooCommerce payment plugins. A gateway might need:

  • script-src for the SDK
  • connect-src for tokenization and API calls
  • frame-src for hosted payment fields or 3DS flows
  • img-src for card brand icons or challenge assets

Fix

Be explicit. Build per-directive allowlists based on what the page actually loads.

A checkout policy often needs something like:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM}' https://js.stripe.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  connect-src 'self' https://api.stripe.com;
  frame-src https://js.stripe.com;
  form-action 'self';
  base-uri 'self';
  object-src 'none';
  frame-ancestors 'none';

If you want a library of patterns, the examples at https://csp-examples.com are useful as a starting point. Don’t paste them untouched into production. WooCommerce stacks are too messy for that.

Mistake #3: Leaving unsafe-inline in script-src forever

This is the classic “we’ll tighten it later” move. Later never comes.

WooCommerce themes and plugins often inject inline scripts for localized settings, fragments, variation handling, tracking, or consent state. That pushes teams into:

script-src 'self' 'unsafe-inline'

That defeats a lot of the value of CSP. If an attacker lands script injection through a plugin bug, unsafe-inline makes exploitation much easier.

The header example above does one thing right:

script-src 'self' 'nonce-...' 'strict-dynamic' ...

That’s a better direction. Nonces let you trust the inline scripts you actually generate.

Fix

Move inline scripts to nonce-based execution where possible.

In WordPress, that usually means adding a nonce to script tags you control and refactoring random inline blobs into enqueued scripts or nonce-bearing inline blocks.

Example nonce generation:

add_action('init', function () {
    if (!defined('CSP_NONCE')) {
        define('CSP_NONCE', base64_encode(random_bytes(16)));
    }
});

add_filter('script_loader_tag', function ($tag, $handle, $src) {
    if (defined('CSP_NONCE')) {
        $tag = str_replace('<script ', '<script nonce="' . esc_attr(CSP_NONCE) . '" ', $tag);
    }
    return $tag;
}, 10, 3);

add_action('send_headers', function () {
    if (defined('CSP_NONCE')) {
        header(
            "Content-Security-Policy: default-src 'self'; " .
            "script-src 'self' 'nonce-" . CSP_NONCE . "' 'strict-dynamic'; " .
            "style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; object-src 'none'; base-uri 'self';"
        );
    }
});

WordPress plugin ecosystems are not perfect here. Some plugins still dump inline JavaScript in ugly ways. If you can’t fully remove inline script yet, isolate the damage: keep unsafe-inline out of script-src even if you still need it in style-src.

Mistake #4: Forgetting that payment providers use iframes and cross-origin requests

If checkout breaks but the rest of the site looks fine, this is usually why.

Payment gateways rarely work as a single script include. They embed hosted fields, run 3DS challenges in frames, and talk to APIs over fetch or XHR. Teams often allow the script domain and stop there.

Then they get errors like:

  • refused to connect to payment API
  • refused to frame payment challenge
  • blocked form action
  • blocked worker or websocket in fraud tooling

Fix

Inspect the actual browser violations on checkout and map them to the correct directive.

A rough checklist:

  • SDK JavaScript: script-src
  • tokenization/API calls: connect-src
  • hosted fields/3DS pages: frame-src
  • redirects or posted forms: form-action
  • fraud beacons or device fingerprinting: often connect-src, sometimes img-src or worker-src

Don’t guess. Run in Report-Only first and collect violations.

Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; report-to csp-endpoint;
Report-To: {"group":"csp-endpoint","max_age":10886400,"endpoints":[{"url":"https://store.example.com/csp-report"}]}

Then watch what checkout actually needs.

Mistake #5: Breaking WooCommerce images, fonts, and CDN assets

Stores are asset-heavy. Product galleries, lazy loading, webfonts, payment logos, review widgets, and image CDNs all need clear allowances.

I still see people ship:

img-src 'self';
font-src 'self';

Then half the catalog silently breaks because images are served from a CDN or transformed through a media service.

The sample header gets one thing right:

img-src 'self' data: https:;

That’s broad, but practical for many stores. If you know your asset hosts, tighten it further.

Fix

Inventory where assets really come from:

  • your origin
  • CDN hostname
  • image optimization service
  • theme/plugin asset hosts
  • data URLs for placeholders or inline SVGs

A more realistic storefront snippet might be:

img-src 'self' data: https://cdn.example.com https://images.examplecdn.com;
font-src 'self' https://cdn.example.com;
style-src 'self' 'unsafe-inline' https://cdn.example.com;

For WooCommerce, don’t forget variation thumbnails, gallery zoom assets, and plugin-provided badge icons.

Mistake #6: Setting form-action 'self' without testing off-site payment flows

form-action 'self' is great until a plugin posts a form to a payment processor or financing provider.

Not every gateway does this anymore, but enough still do that you should test it. Buy-now-pay-later tools and some regional payment methods are common offenders.

Fix

If your checkout or payment confirmation posts to another origin, add it explicitly:

form-action 'self' https://payments.example-gateway.com;

Don’t broaden this casually. form-action is one of those directives that’s easy to forget and painful to debug.

Mistake #7: Ignoring wp-admin and plugin settings pages

The public storefront gets all the attention, but WordPress admin is usually where the gross inline code lives. Plugin dashboards, page builders, analytics integrations, and import/export tools often depend on inline scripts or styles.

If you slam the same strict policy onto /wp-admin/, you can break order management, reports, media uploads, and plugin configuration.

Fix

Use a separate admin policy, and be realistic. Admin can be stricter than “anything goes,” but it often needs more flexibility than the storefront.

I prefer to prioritize a strong public-site policy first. That’s where untrusted users and customer sessions live. Then tighten admin iteratively.

Mistake #8: Rolling out enforcement before Report-Only

I know why this happens. You want to “just secure it.” On an e-commerce site, that’s reckless.

You need to see what your stack actually loads under:

  • normal browsing
  • add to cart
  • checkout
  • logged-in account pages
  • coupon application
  • payment success and failure
  • consent accepted and rejected
  • admin order processing

Fix

Start with Content-Security-Policy-Report-Only, collect violations, clean up the noise, then enforce.

Also: test with all the plugins enabled that matter in production. A staging environment without your real tag manager, consent tool, payment methods, or fraud scripts is lying to you.

My practical baseline for WooCommerce

If I’m hardening a store, I usually aim for this:

  • object-src 'none'
  • base-uri 'self'
  • frame-ancestors 'none'
  • route-specific script-src, connect-src, frame-src, and form-action
  • nonces for scripts where possible
  • no unsafe-inline in script-src
  • tolerate unsafe-inline in style-src if the theme/plugin mess isn’t worth the war yet
  • Report-Only first, always

That’s not academically pure. It is deployable, which matters more.

WooCommerce CSP work is mostly about reducing chaos without killing revenue. If your policy is “secure” but customers can’t pay, you failed. If your policy is so loose that every plugin gets a free pass, you also failed.

The sweet spot is boring, tested, and specific. That’s what you want on a store.