Content Security Policy (CSP) is an HTTP header you use to tell the browser where your page may load resources from and execute code. If an attacker manages to inject a script into the page, a well-configured CSP stops the browser from running it. It does not replace proper input handling, but it is a very effective second line of defence against cross-site scripting.
The problem is that a hastily written CSP breaks the site: blocked scripts, missing styles, widgets that disappear. This article shows how to build one step by step.
The structure of a policy
A CSP is a list of directives, each with its allowed sources, separated by semicolons:
Content-Security-Policy: default-src 'self'; img-src 'self' data:; script-src 'self'
The most commonly used directives:
| Directive | Controls |
|---|---|
default-src |
fallback for all directives not specified |
script-src |
JavaScript files and inline scripts |
style-src |
stylesheets and inline styles |
img-src |
images |
font-src |
web fonts |
connect-src |
requests made with fetch, XHR, WebSocket |
frame-src |
content embedded in iframes |
frame-ancestors |
who may frame your page |
form-action |
addresses forms may submit data to |
base-uri |
allowed values for the base element |
object-src |
plugins such as object and embed |
The most common sources are 'self' (the page's own origin), 'none' (nothing), a specific domain such as https://cdn.example.com, and schemes such as data: or https:. Keywords go in single quotes; domains do not.
The problem with inline scripts
By default, a CSP with script-src blocks all inline scripts: <script> blocks in the HTML and attributes such as onclick. That is precisely the policy's strength, because XSS attacks almost always inject inline code.
The 'unsafe-inline' shortcut re-enables everything, but it cancels out most of the protection. There are two proper alternatives.
Nonces
The server generates a random value that is different on every response and inserts it both in the header and in legitimate scripts:
Content-Security-Policy: script-src 'nonce-R4nd0mV4lu3'
<script nonce="R4nd0mV4lu3">
initMenu();
</script>
An attacker injecting a script does not know the nonce, so their code is blocked. A nonce only works if it is truly unpredictable and regenerated on every request: a fixed nonce written into the template protects nothing.
Hashes
For static sites, where no server generates values per request, you can allow an inline script through the SHA-256 hash of its content:
Content-Security-Policy: script-src 'self' 'sha256-<base64 hash>'
If the script's content changes by even one character, the hash no longer matches and must be updated. Many build tools can calculate it automatically.
The simplest solution
Often the best route is to move inline code into external files and replace onclick attributes with addEventListener. Then script-src 'self' is all you need.
Rolling it out with Report-Only
CSP has a trial mode: the Content-Security-Policy-Report-Only header applies the same rules but blocks nothing; it only reports violations in the browser console and, optionally, to a collection endpoint.
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report
A practical path:
- Publish a strict policy in Report-Only mode.
- Browse every part of the site and collect violations for a few days.
- For each violation decide: is it a legitimate resource to allow, or code to rewrite?
- When the legitimate reports have stopped, switch to the real header.
- Keep collecting reports afterwards, to catch regressions.
The report-uri directive is still very widespread; the newer one is report-to, which relies on a separate header to define the collection endpoints.
A starting policy
For a site that serves everything from its own origin, with no third-party services:
Content-Security-Policy:
default-src 'self';
img-src 'self' data:;
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'self';
upgrade-insecure-requests
(In the real header it all goes on one line.) object-src 'none' and base-uri 'self' close two often-forgotten attack routes; upgrade-insecure-requests asks the browser to load resources referenced over HTTP via HTTPS instead.
Third-party services
Analytics, maps, embedded videos and external fonts require adding their domains to the appropriate directives. Every allowed domain is trust you grant: if that domain also hosts content uploaded by other users, an attacker could use it to slip their own script through. Allow only what you need and, where possible, prefer hosting resources on your own domain.
Common mistakes
- Using
'unsafe-inline'and'unsafe-eval'"to make everything work", defeating the policy. - Forgetting
connect-srcand breaking API calls. - Allowing entire schemes such as
https:inscript-src: it amounts to trusting the whole web. - Putting the policy in a
metatag and expectingframe-ancestorsor reporting to work: they are not supported in that form.
In short
- CSP tells the browser where the page may load resources from and execute code.
- Avoid
'unsafe-inline': use random nonces, hashes, or move code into external files. - Roll it out with
Content-Security-Policy-Report-Onlyand analyse the violations. - Always include
object-src 'none',base-uriandframe-ancestors. - Every allowed third-party domain is a risk: keep the list to what is necessary.