In September I wrote a guide to implementing a Content Security Policy. This post is the practical follow-up: a step-by-step rollout, from the first audit to an enforced policy, that works whether your site is a static build, a WordPress site or a Magento store.
I recently went through this process on this website, and I'll use it as a worked example throughout. The steps are the same on any platform. What changes is where you set the header and which scripts tend to cause trouble.
Where the policy lives
A Content Security Policy is an HTTP response header, so the first question is where your site sets headers:
- Static sites (Eleventy, Hugo, Jekyll and similar): your host's header configuration, such as Netlify's
_headersfile,vercel.jsonor a Cloudflare rule. - WordPress: the web server (
Header setin Apache's.htaccess, oradd_headerin Nginx), a CDN such as Cloudflare, or a headers plugin. Setting it at the server or CDN level is usually more reliable than a plugin. - Magento 2: Magento has a built-in CSP module (
Magento_Csp). You add allowed domains in a module'setc/csp_whitelist.xml, and switch between report-only and enforced mode in configuration, separately for the storefront and the admin.
Step 1: check what you're already sending
Before writing a single rule, check the security headers your site actually sends today. curl -I https://example.com or the Network tab in your browser's developer tools is enough.
When I did this on my own site, two headers I thought were in place, Referrer-Policy and Permissions-Policy, had never been sent. In Netlify's _headers file they had ended up on unindented lines, so Netlify read them as new path rules rather than headers. Configuration that looks right isn't always what the server sends, and the same is true of .htaccess rules and plugin settings.
Step 2: take stock of everything the site loads
Next, list every source of scripts, styles, images, fonts, frames and network requests, because the policy has to allow all of them.
- On a static site you can scan the built HTML directly, because the build output is the site.
- On WordPress and Magento the output depends on plugins, extensions, page templates and whether someone is logged in. Work through a representative set of pages in your browser's developer tools: the homepage, a post or product, a category or listing page, search, the contact form, and for shops the basket, checkout and customer account pages.
The usual suspects are similar on every platform:
- analytics and tag managers (Google Tag Manager in particular loads scripts from many other domains)
- payment providers such as Stripe, PayPal or Global Payments, which use their own scripts and iframes
- reCAPTCHA and other anti-spam tools
- chat widgets, review widgets and social embeds (Instagram, YouTube, X)
- web fonts from Google Fonts or Adobe Fonts
- inline scripts, which are the big one
On my site the audit turned up two analytics tools, Instagram and GitHub Gist embeds on older posts, an inline configuration script on every page, a few inline JavaScript redirects and a single inline onclick. WordPress and Magento sites usually have far more inline scripts, because themes, plugins and extensions add them to pass settings to JavaScript.
One thing you can ignore: JSON-LD structured data (<script type="application/ld+json">). It's data, not code, so browsers never execute it and CSP doesn't block it.
Step 3: deal with inline scripts properly
The easy way out is to add 'unsafe-inline' to script-src, but that undoes most of the protection CSP gives you against cross-site scripting. Instead, work through the inline scripts:
- Remove what you don't need. I was running two analytics tools on every page and only needed one. Removing the other got rid of the only inline script on every page.
- Move small snippets into script files. An inline
onclick="window.print()"became adata-printattribute, handled in a script file that already loaded on every page. - Replace JavaScript redirects with server redirects. These are faster for visitors and better for search engines anyway.
- Use nonces where scripts must stay inline. A nonce is a random value generated for each page load. The server adds it to both the policy and each approved
<script>tag, so only those tags run.- WordPress: since version 5.7 there are helper functions (
wp_get_inline_script_tag()and related) and filters (wp_inline_script_attributes,wp_script_attributes) that let you add a nonce to scripts that are output properly through WordPress. Plugins that echo<script>tags directly won't pick it up, and those are often the hardest part of a WordPress rollout. - Magento: recent versions include a nonce provider and a secure HTML renderer, which templates can use to output inline scripts the policy will accept. Third-party extensions don't always use them yet.
- WordPress: since version 5.7 there are helper functions (
Step 4: accept the trade-offs that don't matter
Not everything is worth fixing. On my site there were around 1,300 inline style attributes, mostly colours and icon sizes. Rewriting them all would have been a big job for very little gain, so I allowed 'unsafe-inline' for styles only. Injected CSS is far less dangerous than injected JavaScript, and this is a common, sensible compromise.
Other common trade-offs:
- Admin areas. The WordPress dashboard and Magento admin load a lot of inline and third-party code. Starting with a policy for the public site only, or a looser one for the admin, is a reasonable first step.
- Legacy JavaScript. Some older libraries need
'unsafe-eval'. Magento's storefront has historically relied on both'unsafe-inline'and'unsafe-eval'for parts of its JavaScript stack. A policy that still has those but strictly limits which domains can serve scripts is still a big improvement on no policy at all.
Step 5: write the policy
Here's the policy for my site, split onto separate lines for readability:
default-src 'self';
script-src 'self' https://scripts.simpleanalyticscdn.com https://gist.github.com https://www.instagram.com;
style-src 'self' 'unsafe-inline' https://github.githubassets.com;
img-src 'self' data: https://queue.simpleanalyticscdn.com https://upload.wikimedia.org https://*.cdninstagram.com https://*.fbcdn.net;
connect-src 'self' https://queue.simpleanalyticscdn.com;
frame-src https://www.instagram.com;
font-src 'self';
form-action 'self';
base-uri 'self';
object-src 'none';
frame-ancestors 'none'
A WordPress site or a shop will have a longer list, typically with payment provider domains in script-src, frame-src and connect-src, and font services in style-src and font-src. Keep each entry as specific as you can: a named domain rather than a wildcard, and never a bare https:.
Step 6: run it in report-only mode first
Don't switch it straight on. Deploy it as Content-Security-Policy-Report-Only first. The browser checks everything against the policy and reports what it would block, but blocks nothing.
- Static hosts and web servers: use the
Content-Security-Policy-Report-Onlyheader name instead ofContent-Security-Policy. - Magento: set the storefront (and admin) CSP mode to report-only in configuration, and optionally give it a report URI.
Then test the pages most likely to break, watching the browser console for messages starting [Report Only] Refused to…:
- contact and newsletter forms (send a real test submission)
- site search
- anything with embeds or widgets
- logged-in pages and accounts
- for shops, a full test order through checkout and payment, ideally with each payment method
On a small site, the console is enough. On a busy WordPress site or shop, add a reporting endpoint with the report-to directive and a service to collect the reports, so you see what real visitors run into across browsers, devices and the pages you didn't think to test. Leave it running for at least a week, longer for a shop with occasional promotions or seasonal pages.
Step 7: enforce it
When the reports stop showing anything unexpected, switch to the enforced header, Content-Security-Policy, or set Magento's mode to restrict.
At the same time, add upgrade-insecure-requests. It tells browsers to load any leftover http:// images or scripts over HTTPS, which is especially useful on older WordPress sites with years of content. Browsers ignore it in report-only mode, so it only makes sense once the policy is enforced.
Know how you'd roll back before you need to. On Netlify that's republishing the previous deploy. On a server it's reverting one line of configuration, and in Magento it's switching back to report-only.
The result
On my site, the enforced policy went live with no problems, and Google's PageSpeed Insights now scores it 100 for Best Practices. Lighthouse still suggests a couple of optional extras, such as nonces or strict-dynamic instead of a list of allowed domains, and Trusted Types. Those are next-level hardening rather than problems.
Keeping it working
A Content Security Policy isn't something you set once and forget. Every new tool, plugin or extension can bring new domains with it, and the browser will quietly block anything you haven't allowed. On WordPress and Magento, plugin and extension updates are the most common cause, so check the console after updates as part of your normal maintenance. I've left a comment at the top of my header configuration to remind future me.
A quick checklist
- Check the headers your site actually sends today.
- Take stock of every script, embed, widget and inline snippet, including checkout and logged-in pages.
- Remove inline scripts, move them into files or use nonces rather than allowing
'unsafe-inline'for scripts. - Decide which trade-offs are acceptable, such as inline styles or a looser admin policy.
- Run the policy in report-only mode and test the pages most likely to break.
- Enforce it, add
upgrade-insecure-requests, and know how to roll back. - Re-check after plugin, extension or theme updates, and whenever you add a new third-party service.
If you'd like a hand rolling out a Content Security Policy on your own WordPress site, Magento store or static site, take a look at my website security services or get in touch.