A Content Security Policy tells the browser which sources it is allowed to load and execute things from. Its main job is to make cross-site scripting survivable: an attacker who manages to inject a <script> tag into your page finds the browser refusing to run it, because your policy never authorised that source.
That protection depends almost entirely on one directive, and almost every real policy weakens it. This site's does. Here's what 'unsafe-inline' actually costs, why it keeps ending up in policies written by people who know better, and how to get rid of it.
Start with the headers that cost nothing
Before CSP, there are four headers with no configuration burden and no way to break a normal site. If you have none of these, add them today and read the rest later.
X-Content-Type-Options: nosniff— stops the browser guessing a content type. Without it, a file you serve as text can be reinterpreted as script.X-Frame-Options: DENY— nobody can embed your page in an iframe, which is what clickjacking needs.Referrer-Policy: strict-origin-when-cross-origin— outbound links get your origin, not the full path. A URL containing a token or an internal path stops leaking to whoever you linked to.Permissions-Policy— switch off the APIs you don't use. A portfolio site has no business asking for a camera, a microphone or a location, and saying so explicitly means injected code can't either.
None of these require thinking about your application. CSP does, which is why it's the one people skip.
What 'unsafe-inline' gives away
A strict script-src 'self' means: run scripts from my own origin, and nothing else. Crucially, an inline <script> block is not "from my origin" — it has no origin, it's just text in the document. So a strict policy blocks inline scripts, and that is precisely the protection you want, because injected XSS is almost always inline. An attacker who can write into your HTML writes a script tag with the payload in it; they rarely get to host a file on your domain.
Adding 'unsafe-inline' to script-src turns that off. Every inline script now runs, including the one an attacker injected. The name is not dramatic for effect — it accurately describes what you've done, which is to remove the main reason for having the header.
Being concrete about this site: the policy here includes script-src 'self' 'unsafe-inline', and the reason is a small inline script in the document head that reads the saved theme and sets a data attribute before first paint. It has to be inline and it has to be blocking — the entire point is that it runs before anything renders, so there's no flash of the wrong colour scheme. Move it to an external file and you've reintroduced the flash you were preventing.
That is a real constraint, and it's the same one that puts 'unsafe-inline' in most policies: one small script that genuinely must be inline, and a directive that can't express "this one" without more work.
The fix, and why it's more work than it looks
CSP does let you authorise specific inline scripts, two ways.
A hash. Take the SHA-256 of the script's exact contents and put it in the policy. The browser hashes each inline script and runs only the ones matching. This suits a static site: the theme script never changes at runtime, so its hash is stable. The catch is that the hash covers the bytes exactly — one whitespace change and the script silently stops running, which for a theme script means a flash of the wrong colours that nobody will connect to a CSP change from three weeks ago. Generate it in the build rather than pasting it in by hand.
A nonce. Generate a random value per request, put it in the header and on the script tag. Matching tags run. This is the better answer for anything server-rendered, and it's a poor fit for a fully static build, because the whole point of a nonce is that it differs per response — a nonce baked into a cached HTML file authorises itself forever, which is not a nonce, just a password an attacker can read.
Both require build or server work. That's why so many otherwise careful policies carry 'unsafe-inline': the fix isn't hard, it just isn't free, and the header looks reassuring either way.
Styles are a lesser problem, and worth separating
style-src 'unsafe-inline' is a much smaller concession than the script equivalent, and it's worth knowing the difference so you spend your effort in the right place.
Injected CSS can do real harm — restyling a form, hiding a warning, exfiltrating some data through attribute selectors and background URLs — but it cannot execute code. Most frameworks and any component that sets a style attribute will need it. Take it if you must, and spend the effort you saved on script-src, which is where the severe outcomes live.
The directives people forget
Three that don't appear in the tutorials but close real gaps:
frame-ancestors 'none'— the modern replacement forX-Frame-Options. Send both; the old header for old browsers, this one because it's what's actually specified.base-uri 'self'— without it, injected content can add a<base>tag and change where every relative URL on the page resolves to, including your own scripts. It costs nothing and it closes a bypass that works around an otherwise sensible policy.form-action— constrains where forms may submit. Otherwise injected markup can point your form at someone else's server, and the user sees a normal submission.
And be specific in connect-src. A policy that locks down scripts but allows connections anywhere still permits injected code to send data out.
Deploy it in report-only first
A CSP that blocks something your site needs produces a page that is broken in a way most people won't report — a widget that doesn't appear, a form that doesn't submit.
So ship it as Content-Security-Policy-Report-Only first. The browser enforces nothing and reports what it would have blocked, and you find out about the analytics script and the embedded map you'd forgotten. Watch it for a week of real traffic — not a click-through, real traffic, because the paths you don't visit are the ones that will break — then switch to enforcing.
What to actually do
Add the four cheap headers now. Add a CSP in report-only mode, watch it, then enforce. Get script-src as tight as you can and treat every 'unsafe-inline' as a known debt rather than a finished job.
And write down why each weakening is there, next to the policy. A directive with a comment explaining the constraint is one someone can revisit when the constraint goes away. One without is permanent, because nobody will ever be confident enough to remove it.