Security & Privacy

Content Security Policy Generator: The Header Nobody Dares Switch On

A Content Security Policy is the header everyone knows they should have and nobody dares enforce. What it actually stops, why a policy with only default-src is either useless or breaks everything, the two keywords that undo the whole thing, and what fails first on a real WordPress site.

Content Security Policy Generator: The Header Nobody Dares Switch On

You add the header on a Friday afternoon. Content-Security-Policy: default-src 'self'. The front end loses its web fonts, the block editor loses half its toolbar, the embedded map goes white, and the cookie banner never appears. You take the header back off. That is where most Content Security Policy projects end, roughly eleven minutes after they start.

The header is not difficult because the syntax is difficult. The syntax is a list of directives separated by semicolons, each with a list of allowed sources. It is difficult because a WordPress page is assembled by a few dozen things you did not write, and every one of them assumes it may drop a script tag, a style attribute or an iframe wherever it likes. A policy is a single statement about all of them at once.

So the useful question is not what a perfect policy looks like. It is which parts of your page already violate the policy you would like to have, in what order, and which of those violations you can actually fix.

What the header stops, and what it does not

A Content Security Policy is enforced by the browser, at the moment a resource is about to load or a script is about to run. The browser compares the request against the matching directive. No match, no load, and a console error naming the directive that refused. Nothing about it runs on your server.

That makes it good at one class of problem: script that arrives inside your HTML from somewhere it should not have. A comment or a custom field rendered without escaping, a settings value echoed straight into an admin page, a third party script host that gets compromised and starts serving something new, an inline script inside a file a user uploaded. In each case the payload is already in the page, and the policy is the last thing between it and execution.

It stops none of the other things. Server side code runs before the browser sees a byte, so a backdoor in a theme function is unaffected, a vulnerable plugin still writes its rows, and a stolen administrator password is still an administrator password. A policy allowing 'self' for scripts also allows any .js file sitting on your own domain, including one an attacker wrote into your uploads folder. The header is a second lock on a door you still have to keep shut.

Security header and CSP builder

Build a Content-Security-Policy and the headers that travel with it, with every switch explained: what it controls, what an attacker gets back when you leave it open, and what usually breaks the moment you close it. Everything is worked out in this browser tab, nothing is uploaded, nothing is fetched and no report is sent anywhere.

Start from

A starting point, not an answer. Most sites end up somewhere between the first two, and the third one is there to be read, not to be used.

Content-Security-Policy
The fallback for every fetch directive you do not list.

Tightening this breaks whatever you forgot to think about. A plugin that pulls a script from its own CDN simply goes quiet: nothing on the page says so, there is one red line in the console and a support ticket a week later.

Where JavaScript may come from, and whether it may sit inline in the page.

Tightening this breaks inline onclick= handlers, every <script> block a theme or plugin prints into the page, and anything that builds code out of a string. The way back in is a nonce, not 'unsafe-inline'.

Where CSS may come from, inline <style> blocks and style= attributes included.

Tightening this breaks the layout before it breaks anything else. WordPress and every page builder write style= attributes straight into the markup, so dropping 'unsafe-inline' here usually costs you the design. Google Fonts needs fonts.googleapis.com in this row, the stylesheet lives on that host.

Where images, favicons and CSS background images may come from.

Tightening this breaks inline images. Base64 pictures and most inline SVG icons need data:, and without it placeholder graphics, editor previews and a lot of plugin icons turn into empty boxes. Avatars and a CDN each need their own host here.

Where font files may be fetched from.

Tightening this breaks the typeface, quietly. Google Fonts serves the files from fonts.gstatic.com, so that host goes here while the stylesheet host goes in style-src. Miss either of the two and the page falls back to a system font with no error a visitor would notice.

Where fetch, XHR, WebSocket, EventSource and sendBeacon may go.

Tightening this breaks the analytics beacon first, and it breaks it silently: the page looks perfect and the numbers stop. Plausible, Matomo and GA4 each need their own host in this row, and so does any script talking to a REST API that is not on your domain.

What may be loaded into an iframe on your page.

Tightening this breaks embedded video and maps. YouTube needs www.youtube.com or www.youtube-nocookie.com, Vimeo needs player.vimeo.com. Leave the row switched off and default-src decides instead, which with 'self' makes every embed a blank rectangle.

Who may put your page inside an iframe. This is the clickjacking control.

Tightening this breaks your own preview frames, a few page builders and the odd payment provider that frames the checkout on purpose. It is not a fetch directive, so default-src never covers it: leave the row off and there is no restriction at all.

Where a form on your page is allowed to submit to.

Tightening this breaks a newsletter or checkout form that posts straight to an external provider. Name that host or the submit is refused, and the visitor sees a page that did nothing when they pressed the button. In return, an injected form cannot post the login to somebody else.

What a <base> tag is allowed to set.

Tightening this breaks next to nothing. Hardly any site sets a base tag, while an injected one repoints every relative URL on the page, script sources included, without changing a single line of your markup. This row is the cheapest one in the policy.

Plugin content: <object>, <embed> and the last of the applets.

Tightening this breaks nothing built this decade. It was a dependable way into a page for the decade before that, which is why 'none' here is the one line every published recommendation agrees on. A PDF shown with <embed> is the one case worth checking.

Rewrites every http:// request the page makes to https:// before it leaves the browser.

Tightening this breaks anything that genuinely only exists over plain HTTP: it becomes a failed request instead of an insecure one. In exchange it clears the mixed content warnings out of ten years of old post bodies without touching the database. It takes no sources, so this row is a switch and nothing else.

Each row offers only the keywords that directive actually honours. 'unsafe-inline' and 'unsafe-eval' mean nothing to img-src or connect-src, so they are not offered there. A row switched off is left out of the policy, and for a fetch directive that means default-src decides in its place.

The policy
What this policy gives away

    Worth keeping in mind: 'self' covers everything on your own domain, the uploads folder included. A policy is the second lock on the door, never the first one.

    What is likely to break
      The other headers
      Tells the browser to reach this domain over HTTPS only, and to remember that for the age you set.

      Tightening this breaks nothing while the certificate is valid, and everything for the length of the max-age once it is not. A browser that has seen the header refuses plain HTTP for that long and offers no way past the warning. Start at five minutes, raise it when the site is genuinely HTTPS everywhere. includeSubDomains extends the same promise to every name under the domain, staging and internal hosts included.

      Referrer-Policy How much of the current URL is handed over when a visitor clicks through to somewhere else.

      Stops the browser from second guessing a file's type against the one you declared.

      Tightening this breaks almost nothing. The one case that does is a server sending the wrong Content-Type, a stylesheet as text/plain for instance, which browsers used to rescue and now refuse. That is an existing bug becoming visible, not a new one. In return, an uploaded file that a browser might have decided to treat as a script stays a download.

      X-Frame-Options The 2010 answer to clickjacking, kept for browsers that never learned frame-ancestors.

      A browser that understands frame-ancestors ignores this header completely, so the two only have to agree for the old ones. That also means this header can never make a policy stricter, only a browser without CSP support behave. ALLOW-FROM never worked in Chrome and is not offered here: a list of allowed sites belongs in frame-ancestors, which takes one.

      Permissions-Policy Which browser features this page, and anything it embeds, is allowed to ask for.

      Tightening this breaks the features inside your embeds first. A feature you leave unset keeps the browser default, which for most of them means allowed on your own page and blocked inside a third party frame. Blocking one here blocks it for the embed too, so a video player with a fullscreen button needs fullscreen left on, and a hosted payment frame needs payment.

      Paste this into the server
      Apache .htaccess or the vhost, mod_headers has to be loaded
      nginx in the server block, and repeated in any location that sets headers of its own
      PHP before a byte of output, the only place a real nonce can be made
      Report only the same policy with nothing blocked, for a week of watching before you enforce it
      WunderPaint
      The Dynamic Design and Automation Studio
      WunderPaint is a layered image editor for your WordPress media library. WunderPaint Studio is the same thing in any browser, free and without an account.

      The builder runs entirely in your browser tab. Nothing is uploaded, fetched or reported anywhere, which also means it cannot inspect your site for you: it builds a policy, it does not read one back.

      The fallback chain

      default-src is not a global default. It is the fallback for one family of directives, the ones that govern fetching a resource: script-src, style-src, img-src, font-src, connect-src, media-src, object-src, worker-src and, through child-src, frame-src. Leave one of those out and the browser uses default-src. Set it and it replaces the fallback for that directive completely, it does not add to it.

      Three of the directives people care most about sit outside that family and inherit nothing. frame-ancestors, form-action and base-uri are unset unless you write them out. So default-src 'self' alone does not restrict who may frame your login page, where a form may post, or what a stray <base> tag may do. That last one matters: inject a base tag and every relative script src on the page points at someone else’s host.

      Table showing which Content-Security-Policy directives fall back to default-src. script-src, style-src, img-src, font-src, connect-src and frame-src inherit from it; frame-ancestors, form-action and base-uri inherit nothing and stay unset unless written out. A code panel contrasts a default-src only policy that blocks everything with one loose enough to allow any HTTPS script.

      Which is why a policy consisting only of default-src lands in one of two states. Tight, as in default-src 'self', and it blocks your fonts, embeds, analytics and every inline block while leaving framing and form posts wide open. Loose, as in default-src 'self' 'unsafe-inline' https:, and it permits any script from any HTTPS host anywhere, which is close to permitting exactly what an attacker wanted. The row per directive layout exists to push you off that fence.

      default-src 'self';
      script-src 'self' 'nonce-{nonce}';
      style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
      font-src 'self' https://fonts.gstatic.com;
      img-src 'self' data: blob:;
      connect-src 'self' https://www.google-analytics.com;
      frame-src https://www.youtube-nocookie.com;
      frame-ancestors 'self';
      form-action 'self';
      base-uri 'self';
      object-src 'none'

      The two keywords that undo the policy

      'unsafe-inline' in script-src tells the browser to run any script written directly into the HTML: a <script> block, an onclick attribute, a javascript: URL. Injected script is, by definition, script written directly into the HTML. The keyword does not weaken the policy, it removes the reason the policy exists.

      'unsafe-eval' is narrower and still bad. It re-enables eval(), new Function() and string arguments to setTimeout, which is how a payload that arrived as data becomes a payload that runs. Some older template libraries genuinely need it. If one of yours does, that is worth knowing as a fact about your stack rather than as a checkbox you tick to silence an error.

      Nonces and hashes

      A nonce is the honest way out. You generate a random value per response, put it in the header as 'nonce-abc123' and on every inline script tag you trust as nonce="abc123". The browser runs the tags carrying the matching value and refuses the rest. An injected script cannot carry it because the attacker does not know it, which is the whole trick. It has to be unpredictable and it has to change every response: 16 bytes from random_bytes(), base64 encoded, is the standard shape.

      Two consequences follow. A nonce cannot come from a static config file, which is why the builder leaves a literal {nonce} placeholder in the policy, generates a real one in the PHP output, and marks the Apache and nginx blocks with a comment: neither server can produce a per request value. And a nonce is in direct conflict with a full page cache: if the cache serves yesterday’s HTML with yesterday’s nonce, either the header no longer matches and everything breaks, or the header is cached too and the nonce is now a public constant.

      Hashes are the alternative for inline blocks that never change. Take the SHA-256 of the exact script text, base64 it, list it as 'sha256-...'. The browser prints the hash it wants in the console error, so you rarely compute one by hand. The catch is that it covers the bytes exactly: one added space, one plugin update, and the block is blocked again. Hashes also do nothing for onclick attributes unless you add 'unsafe-hashes'.

      One detail to know before you start: as soon as a nonce or hash appears in script-src, browsers ignore 'unsafe-inline' in that same directive. That is deliberate, so old browsers fall back to the permissive keyword while modern ones enforce the strict one. It also means a policy carrying both is not the compromise it looks like.

      What breaks first on a WordPress site

      Inline styles go first. Block supports in core turn colour, spacing and typography settings into style attributes and into inline <style> tags printed per block, and theme.json adds more. Without 'unsafe-inline' in style-src the page loads with its layout half applied, which reads as a broken theme rather than as a security header.

      Inline scripts go next, and there are more than you expect. Core prints the emoji detection script into the footer of every front end page through wp_print_inline_script_tag(). Every call to wp_localize_script() or wp_add_inline_script() becomes another inline block, and the block editor ships its settings payload that way. Anything core prints through those helpers passes its attributes through the wp_inline_script_attributes filter, so a nonce can be attached in one place. Plugins that echo a script tag straight into wp_footer never touch that filter and stay broken.

      // One nonce per request, reused by the header and the filter.
      define( 'MY_CSP_NONCE', base64_encode( random_bytes( 16 ) ) );
      
      add_filter( 'wp_inline_script_attributes', function ( $attr ) {
          $attr['nonce'] = MY_CSP_NONCE;
          return $attr;
      } );

      Third is the page builder: Elementor, Divi and their relatives emit per widget inline CSS and per page inline JavaScript configuration. Fourth is the Google Fonts pair, which is two directives and not one: the stylesheet comes from fonts.googleapis.com and belongs in style-src, the font files come from fonts.gstatic.com and belong in font-src. Allow only one and the page appears to work until you look at the letterforms.

      Then the embeds. A YouTube player, a map and a reCAPTCHA widget are all iframes and all need their host in frame-src. Analytics is usually two directives as well: the tag script under script-src, the beacon it sends under connect-src. And img-src wants data: for the inline icons in admin CSS plus blob: for the media library’s upload previews, which are object URLs rather than files. If images vanish after you set the header, the console names the cause in one line, where the other five layers of image trouble take an afternoon to isolate.

      Table ranking what breaks first when a Content Security Policy is enforced on a WordPress site, with proportional bars. Inline styles from block supports and theme.json are the most likely failure, then inline scripts such as the emoji detection script and localized JSON, then page builder inline configuration, Google Fonts files, iframe embeds, the analytics beacon, data and blob image sources, and third party form posts.

      Report-only is the only sane start

      Send the same policy as Content-Security-Policy-Report-Only and the browser enforces nothing. It evaluates every request against the policy, logs each violation to the console, and if you supply a report-uri it POSTs a small JSON body naming the document, the violated directive, the blocked URI, and the source file and line number. Both headers can be sent at once, so a strict report-only policy can run alongside a loose enforced one while you work.

      Leave it running for a week, admin included, and let real traffic find what you would never click yourself: the checkout, the password reset template, the one landing page with an old embed on it. Two warnings. Browser extensions inject their own scripts into your pages and generate violations you cannot fix, so much of what arrives is noise from other people’s software. And for cross origin blocks the report strips the path and gives you the origin only, which identifies a host and never a file.

      The report-only output is generated for all three server configurations from the same policy you built, so the switch from watching to enforcing is a header name and nothing else. The report-uri field feeds that variant only.

      frame-ancestors and the header it replaces

      X-Frame-Options has two usable values, DENY and SAMEORIGIN. Its third, ALLOW-FROM, was never implemented in Chrome or Safari. frame-ancestors does the job properly: it takes 'none', 'self' or a list of origins, so one partner domain can frame you and nobody else.

      Where both headers are present, frame-ancestors wins in every browser that supports it and the old header is ignored. Keeping both is reasonable for very old clients, as long as they agree. They frequently do not: X-Frame-Options: DENY next to frame-ancestors 'self' means your own site can frame your pages in a modern browser and cannot in an ancient one, which produces a bug report nobody can reproduce. The builder flags that disagreement instead of silently picking a winner. One more limit: frame-ancestors, report-uri and sandbox are all ignored when a policy is delivered in a <meta> tag, so framing protection has to be a real header.

      HSTS preload is a one way door

      Strict-Transport-Security tells a browser to use HTTPS for your host for the next max-age seconds, whatever a link says. That part is reversible: lower the value, wait it out, the memory expires. includeSubDomains extends it to every subdomain, including the staging box, the mail interface and the subdomain whose certificate nobody has renewed in two years. Check that list before ticking it.

      preload is the one you cannot take back on your own schedule. Submitting a host to the preload list bakes it into the browser binaries, so a fresh install enforces HTTPS for your domain before it has ever contacted you. Entry requires a max-age of at least one year, includeSubDomains, and an HTTP to HTTPS redirect on the same host. Removal is a request form followed by a wait for browser releases to ship it, realistically months, which is why the option sits behind an explicit warning rather than next to the other checkboxes.

      Where the header actually gets set

      Three places, and the difference is not cosmetic. A PHP header() call, whether it lives in functions.php or a small must use plugin, is the only one of the three that can produce a per request nonce. It is also the only one that never covers a static file, because a request for a CSS file, a font or an uploaded image is answered by the web server without PHP being involved at all.

      That gap is where the SVG case comes in. An SVG is a document format that can carry a <script> element. Loaded through an <img> tag it never executes, which is why the risk is often waved away. Opened directly at its own URL under /wp-content/uploads/ it is a top level document served from your origin, with your cookies, and the script inside it runs. A policy without 'unsafe-inline' in script-src stops that dead, but only if the header is on that response, which means it has to come from the web server. Pair it with the sanitising in why WordPress blocks SVG uploads and how to allow them safely, and confirm which directory your uploads are actually served from before writing the rule.

      # Apache: applies to every response the server sends, PHP or not.
      <IfModule mod_headers.c>
        Header always set Content-Security-Policy "default-src 'self'"
      </IfModule>
      
      # nginx: an add_header anywhere in a location block discards
      # every add_header inherited from the server block.
      add_header Content-Security-Policy "default-src 'self'" always;

      The nginx caveat catches people constantly. Header directives do not accumulate down the configuration tree: the moment a location block contains one add_header of its own, every add_header from the enclosing server block stops applying inside it. A site with a separate location block for PHP, or one for static assets with cache headers, quietly loses its security headers there. The always flag solves a different problem, keeping the header on error responses such as a 404 or a 500.

      What the builder does not do

      It does not read an existing policy back in: one you already run has to be rebuilt row by row, which takes two minutes and forces you to look at every line. It does not hash inline blocks, so a site that prefers hashes to nonces takes the values from the browser console. There is no trusted-types, no split into script-src-elem and script-src-attr, and no report-to, only report-uri, because it is the reporting mechanism that works everywhere.

      Some constraints are deliberate teaching. Each row offers only the keywords that directive actually honours, so you cannot tick 'unsafe-eval' on img-src and wonder why nothing changed. A row switched on with nothing ticked emits 'none' rather than an empty value. 'none' typed next to a real host is passed through exactly as written and then flagged, because browsers ignore that combination and seeing it is better than having it quietly corrected. Host fields take a host, a scheme or a wildcard, filtered against a whitelist with no quotes, backslashes or semicolons in it, and anything dropped is named back to you rather than silently discarded.

      The reason this header has such a reputation is that it is usually attempted backwards. People take the strictest policy they can find in a blog post, enforce it on production, and discover their site’s actual composition through a support ticket. Started the other way round, in report-only, with a policy that names every directive rather than leaning on default-src, it becomes an inventory: every host this page talks to, every inline block, and the handful with no remaining reason to exist.

      Most sites land somewhere honest rather than somewhere perfect. style-src keeps 'unsafe-inline' because WordPress will not stop writing style attributes. script-src gets a nonce and loses 'unsafe-inline', which is the change that actually buys something. object-src 'none', base-uri 'self', form-action 'self' and frame-ancestors cost nothing and close doors default-src never touched. That policy will not score a gold star on a scanner report, and it stops the attack the scanner was worried about.

      Treat it like the rest of the boring maintenance: set deliberately, written down, revisited when the site changes. It belongs in the same afternoon as rotating the keys and salts nobody has touched since installation. Neither job is interesting, and both are the difference between a bad day and a very bad week.

      Content Security Policy Generator: The Header Nobody Dares Switch On

      Table of Contents

      Learn it by building something

      Every week one thing you can make the same afternoon, from dynamic templates to 3D type. Written down step by step.

      One mail a week, and then it ends.
      Unsubscribe in one click.

      Security & Privacy

      Content Security Policy Generator: The Header Nobody Dares Switch On

      A Content Security Policy is the header everyone knows they should have and nobody dares enforce. What it actually stops, why a policy with only default-src is either useless or breaks everything, the two keywords that undo the whole thing, and what fails first on a real WordPress site.

      CSS & Front-End

      CSS Gradient Generator: Gradients That Do Not Band

      A gradient that looked smooth in the design tool grows stripes on the page. The cause is arithmetic: 8 bit colour, two similar colours and 1600 pixels to cover. How to count the steps before you ship, the three real fixes, and a builder that measures the banding for you.

      WordPress Images

      How to Convert Images to WebP in WordPress

      Core converts on upload through a single filter and ignores everything already in the library. Here is what that filter really covers, what regeneration adds, and why the uploads folder grows before it shrinks.

      Security & Privacy

      Stopping WordPress Hotlinking Without Breaking Image Search

      Hotlinking is another site's HTML pointing at your image URL, so your server pays for their page. Prove it from the access log before you block anything, then write a referrer rule at the web server level that still lets crawlers, CDNs and link previews through.

      Speed & Performance

      WordPress Media Library Slow: What Actually Causes It

      A media library that takes thirty seconds is not one slow thing. It is a database query, a metadata prime, a JSON build in PHP and eighty image requests, stacked on top of each other. Here is how to measure which one is hurting your site, and the fix order that actually moves the number.

      WordPress Images

      Upload Simulator: What WordPress Actually Writes to Disk

      One 3000 by 2000 photo becomes eight files on disk. A 1200 by 800 photo becomes five. Drop your own file in and see exactly which registered sizes fire, which ones core refuses, what dimensions come out and what the whole set weighs.

      Download the free WunderPaint Plugin for WordPress

      The WunderPaint workspace with the layers panel, adjustment sliders, text style presets and the asset library along the bottom

      The Image Editor & Design Studio

      Everything described here can be done in the browser, on your own site. The live demo runs the full editor with nothing to install.

      Free

      Chaos Art

      Autonomous painters make one-of-a-kind abstract art in 3D space - gestures, art movements, painterly media, and embeds that paint a new original for every visitor.

      Pro

      Particle Strokes

      Paint with swarms of light: twenty-two movements, a stamp you draw yourself, and curves that give a stroke a shape - the swarm keeps painting for a few seconds after you let go.

      Pro

      City Diorama

      Any place on earth as a miniature you could hold: real streets, water and building footprints raised into a 3D diorama - or wrapped around a sphere as your own tiny planet.

      Free

      Papercut Art

      Layered paper pictures with real depth - a photo sliced along its actual depth into up to twenty layers, parametric landscapes, animals and clouds you can shape, letters with real counters, and a look that runs on three dials.

      Pro

      3D Earth Studio

      A hyperrealistic globe - day and night with city lights, live clouds, atmosphere halo, country borders and highlights, click-to-place markers with flight-route arcs, satellite orbits, seamless rotation video and a live website embed.

      Free

      Mystic Studio

      Turn a birth date into wall art - a real natal chart with houses and aspects, the moon of that night, zodiac and Chinese zodiac posters, numerology cards and a synastry wheel for two, in eight artful themes.

      Free

      Marble Bath

      Marble paper on a virtual water bath - drop, rake and comb real Ebru patterns with gestures, flowers and classic recipes, razor-sharp at any size and re-editable as a layer.