Most advice about a WordPress Content Security Policy goes one of two ways. Either it is a single copy-and-paste line that breaks half your site, or it is a wall of theory that never gets to a policy you can actually run. This article sits in the middle: enough theory to make sensible choices, then a starter policy and a rollout plan.
If you have never touched security headers, read our guide to WordPress security headers first. It covers the basics and the easy headers. This one goes deeper into the hardest and most powerful of them.
What a Content Security Policy Does
A Content Security Policy (CSP) is an allow list that your server sends to the browser with each page. For every kind of content, such as scripts, styles, images and frames, you say where it may come from. The browser then refuses anything that is not on the list.
Picture a shop with a staff-only door. Without a policy, the browser lets in anyone who turns up carrying a script. With one, it checks the name against your list and turns the rest away.
The main reason this matters is cross-site scripting (XSS). That is when an attacker gets their own JavaScript to run on your page, for example through a vulnerable plugin. The script can steal sign-in cookies, rewrite forms or send visitors elsewhere. A good policy does not remove the vulnerability, but it can stop the browser from running the injected script or sending data to the attacker's server.
Key takeaway: A CSP is a second layer. It limits what an injected script can do. It does not replace updates, strong passwords or a firewall.
The full reference for how browsers treat each directive is on MDN, in the Content Security Policy guide. Keep it open in another tab while you work.
The Directives That Matter Most
A policy is a list of directives separated by semicolons. Each directive covers one kind of content. There are many, but a handful do most of the work on a typical WordPress site.
| Directive | Controls | Common WordPress values |
|---|---|---|
default-src | The fallback for anything without its own directive | 'self' |
script-src | JavaScript, including inline script | 'self', a nonce, analytics domains |
style-src | CSS files and inline styles | 'self', 'unsafe-inline', Google Fonts CSS |
img-src | Images | 'self', data:, https: |
connect-src | Fetch, XHR and WebSocket targets | 'self', analytics and API endpoints |
frame-ancestors | Who may embed your pages in a frame | 'self' or 'none' |
default-src
This is the safety net. If a content type has no directive of its own, the browser falls back to default-src. Setting it to 'self' means "only my own domain, unless I say otherwise", which is a good starting point because anything you forgot is blocked rather than allowed.
script-src
This is the directive that does the most good, and causes the most pain. It controls which scripts may run. A tight script-src is what stops an injected script from executing.
style-src
Styles are lower risk than scripts, but they still matter. Many themes and page builders print inline styles, so a strict style-src is often the last directive people tighten. A common compromise is to allow 'unsafe-inline' for styles while keeping scripts locked down.
img-src
Images rarely cause security trouble, but they break visibly when blocked. Most sites allow 'self', data: (for tiny embedded images) and the domains of their image or avatar services.
connect-src
This one controls where scripts can send requests to. It matters because a lot of attacks end with stolen data being posted to an outside server. Listing only your own domain and the services you use makes that harder.
frame-ancestors
This controls who may put your pages inside a frame on their site. Its job is to prevent clickjacking, where an attacker overlays your page in a hidden frame and tricks someone into clicking. MDN notes that unless your site needs to be embeddable, you should set it to 'none'. If you embed your own pages (for example in a preview), use 'self'.
Why WordPress Makes CSP Hard
A plain static site is easy to lock down: you know every script on it. WordPress is a different animal, because nobody wrote the whole page. Your theme, a dozen plugins and some pasted snippets all add to it.
Here is where the trouble usually comes from:
- Inline scripts. Plugins and themes often print JavaScript straight into the page, such as a small block that passes settings to a script. A strict
script-srcblocks all of it. - Page builders. Editors that preview your page live often rely on inline code, extra frames and
eval. The front end of the site may work while the editor does not. - Analytics and tag managers. Each tool loads scripts from its own domain and then often loads more scripts from other domains.
- Embeds. YouTube, maps, booking widgets and payment forms all need frames or scripts from outside domains.
- Fonts and icon libraries. Both a stylesheet and a font file are involved, and they can come from two different places.
- Plugin updates. A plugin update can add a new outside domain overnight. A policy that worked last month can start blocking something today.
None of this means you should give up. It means you build the policy from evidence (what your site really loads), not from guesswork.
Nonces, Hashes and unsafe-inline
Inline script is the biggest obstacle, so it is worth understanding your three options. These are the same options MDN describes.
unsafe-inline
Adding 'unsafe-inline' to script-src allows every inline script. That includes the one an attacker injects, which is exactly what you were trying to stop. MDN recommends avoiding it. It is the escape hatch, and it removes most of the benefit.
Nonces
A nonce is a random value that the server creates for each page response. It goes in the policy and on every script tag you trust:
Content-Security-Policy: script-src 'self' 'nonce-4f9a7c21b8e0'
<script nonce="4f9a7c21b8e0">/* trusted inline code */</script>
The browser only runs inline scripts whose nonce matches. An attacker cannot guess a fresh random value, so their injected script is blocked. The nonce must be unpredictable and different on every response, which also means nonces and full-page caching do not mix well unless the cache handles them.
In WordPress, scripts added the proper way, with wp_enqueue_script(), can be given a nonce by filtering the tag. Scripts that a theme or plugin prints directly into the page, without going through that system, will not get one.
Hashes
A hash is a fingerprint of one specific script. You work out the SHA-256 (or 384 or 512) value, encode it and put it in the policy. The browser runs an inline script only if its content produces that exact hash.
Hashes suit scripts that never change. They are awkward on WordPress, because a plugin update that changes one character in an inline script changes the hash, and the script quietly stops working.
Pro tip: If you add a nonce or hash to a directive, browsers ignore
'unsafe-inline'in that directive. Do not rely on it as a fallback.
Which One Should You Choose?
| Option | Safe against injected script | Maintenance on WordPress | Best for |
|---|---|---|---|
unsafe-inline | No | None | Styles, as a last resort |
| Nonce | Yes | Low if scripts are enqueued properly | Most WordPress sites |
| Hash | Yes | High, breaks on updates | A few fixed inline snippets |
For most sites the practical answer is a nonce for scripts and 'unsafe-inline' for styles, until you have time to clean up inline styles.
Start in Report-Only Mode
Here is the rule that saves most people from a bad day: never begin with an enforced policy. Begin with report-only.
In report-only mode, you send the header Content-Security-Policy-Report-Only instead of Content-Security-Policy. The browser checks every resource against your policy, blocks nothing, and sends a report for each violation. MDN confirms the policy is not enforced but violations are still reported. Visitors notice nothing, and you get a list of everything your policy would have broken.
Two practical points:
- A report-only policy can only be sent as an HTTP header. It cannot be set in a
<meta>tag, per MDN. - You can send both headers at once. That lets you enforce a loose policy while you test a stricter one in report-only mode.
Where Do the Reports Go?
Browsers send each violation as a JSON report to an address you give them. MDN describes a newer way using the report-to directive with a Reporting-Endpoints header, and an older report-uri directive that it marks as deprecated but still suggests declaring until report-to is supported everywhere.
You can send reports to a third-party service, or to a collector on your own site. Either way, expect noise. Browser extensions that inject scripts produce a lot of reports that have nothing to do with your site.
Reading Violation Reports
A violation report has a few fields worth knowing:
- Blocked URL. What the browser refused to load, such as
https://cdn.example.com/widget.js. For inline code, this is often justinline. - Violated directive. Which part of your policy said no, for example
script-src-elem. - Document URL. The page where it happened.
- Source file and line. Where the offending code was, when the browser can tell.
Here is how to read a small batch of reports:
- Group them by blocked URL. Ten reports about the same domain are one decision, not ten.
- Ask whether you know it. Your payment provider or video host belongs on the list. A domain you have never heard of does not.
- Add trusted sources to the right directive. A script goes in
script-src, an image inimg-src, a frame inframe-src. - Ignore the junk. Reports with a blocked URL like
chrome-extension://come from visitors' browsers, not your site. - Investigate anything odd. An unfamiliar script that appears on a page it should not be on is exactly the kind of thing a policy exists to reveal. It can be a sign of compromise, so treat it as one until proven otherwise.
That last point is a quiet bonus of the whole exercise. A violation log is a record of everything your site tried to load, and sometimes it shows something you did not expect.
A Starter Policy for WordPress
This is a reasonable first draft for a typical content site. It is meant to run in report-only mode, not to be pasted in and enforced.
Content-Security-Policy-Report-Only:
default-src 'self';
script-src 'self' 'nonce-RANDOM_PER_REQUEST';
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: https:;
connect-src 'self';
frame-src 'self' https://www.youtube-nocookie.com;
frame-ancestors 'self';
object-src 'none';
base-uri 'self';
form-action 'self'
A few notes on the choices:
object-src 'none'blocks old plugin formats that almost no site uses.base-uri 'self'stops an attacker from changing the base address that relative links resolve against.form-action 'self'stops forms from posting to an outside address. If you use a hosted form or payment page, add its domain.img-srcallowshttps:to avoid breaking images from many places at first. Tighten it later once your reports show where images come from.- The font lines assume Google Fonts. Remove them if you host fonts yourself.
Your site will differ. A shop needs payment domains, a site with analytics needs those domains in both script-src and connect-src, and a site with a map needs the map provider. The reports will tell you which.
Rolling Out a WordPress Content Security Policy
Treat this like a change to a live shop, not a quick tweak. Here is a plan that works.
- Back up first. If something goes wrong, you want a way back. Our guide to how to back up a WordPress site covers it.
- Use a staging copy if you have one. Header changes are easy to test there without anyone noticing.
- Make sure HTTPS is solid. Mixed content (insecure files on a secure page) will fill your reports with noise. See how to force HTTPS in WordPress to sort that first.
- Turn on report-only mode with your starter policy.
- Browse your own site thoroughly. Visit the home page, posts, search, contact forms, the checkout and any page with embeds. Do it logged in and logged out.
- Wait a few days and read the reports. Real visitors use the site in ways you do not.
- Add trusted sources, remove guesses, repeat until new reports are only junk.
- Switch to enforce during a quiet period, and check the site straight after.
- Keep watching. After each plugin or theme update, glance at the reports again.
Be careful with the admin area. The block editor, media library and some plugin screens use inline code and blob: URLs. Many sites only send the policy on the public site, or keep the admin policy looser. If you enforce in wp-admin, test every screen you use.
Building a WordPress Content Security Policy With Stack Guard
You can write headers by hand in your server config or a small plugin, and that is a fine approach if you are comfortable with it. If you would rather work from a settings screen, Stack Guard Pro includes a Content Security Policy builder.
Here is what it does, as described in the Content Security Policy documentation:
- Presets. Pick a quick preset close to your site, such as WordPress Compatible or WooCommerce, then adjust the directives below it.
- Separate settings for each directive. That includes the ones in this article:
default-src,script-src,style-src,img-src,connect-srcandframe-ancestors, plus others likefont-src,frame-src,object-src,base-uriandform-action. An empty directive is left out. - Nonces. An option adds a fresh random nonce to the scripts and stylesheets that WordPress loads properly. Scripts that a theme or plugin prints straight into the page without going through WordPress will be blocked, which is the same limit described above.
- Report-only and enforce modes. Report-only changes nothing for visitors. Enforce actually blocks.
- A violation log. With collection switched on, browsers report to your own site, and the most recent reports are kept so you can read them in wp-admin before you enforce anything.
- A test score. It rates the saved policy, so save before you test.
The policy is only sent while the plugin's core security headers are switched on. The builder also works while the mode is disabled, so you can prepare a policy before it goes live.
The other headers that sit next to CSP, such as HSTS and the frame and referrer headers, are covered on the security headers feature page, and the same page explains which plan includes what.
If your site has a nonce-based form or a custom admin screen of your own, our article on the WordPress nonce explains a different kind of nonce that is easy to confuse with a CSP one. They share a name and nothing else: a form nonce proves a request came from your page, and a CSP nonce marks a script as trusted.
Common Mistakes
- Enforcing on day one. Always report first.
- Adding
'unsafe-inline'and'unsafe-eval'to every directive. The policy then looks strict and protects very little. - Allowing a whole CDN. A wildcard like
https://*.cloudfront.nettrusts every site hosted there, not just yours. - Forgetting that policies are per response. A cache or a server rule that adds the header to some pages and not others leaves gaps.
- Never looking at the reports again. A policy that nobody reads goes stale.
- Treating it as a fix for an infected site. A policy limits damage. If you think you have been hacked, follow our guide to recovering a hacked WordPress site.
A WordPress Content Security Policy is not a quick win, but it is one of the few controls that still helps when something else has already gone wrong. Start in report-only mode, let the evidence decide, and enforce when the reports go quiet.