The Content-Security-Policy (CSP) is an HTTP response header that tells the browser which content sources it is allowed to load and run. Set well, it turns a script-injection flaw into a mere blocked entry in the console. Misunderstood, it becomes a copy-pasted line that stops nothing or breaks half the site. This guide describes a progressive rollout, from directives to test mode, for a baseline that protects without paralysing production.
What a Content-Security-Policy locks down
Cross-site scripting (XSS) remains one of the most common web vulnerabilities: an attacker manages to inject JavaScript into a page, and that code runs with the victim’s privileges. CSP acts as defence in depth. Even if an injection slips past input validation, the browser refuses to run a script whose origin is not explicitly allowed by the policy. The header does not replace output escaping; it adds a second barrier that sharply limits the impact of an application mistake.
Anatomy of the header: the directives to know
A policy is a list of directives separated by semicolons. Each directive names a resource type and its list of allowed sources, expressed as keywords ('self', 'none') or as domains.
Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self'; connect-src 'self' https://api.example.com; object-src 'none'| Directive | Role |
|---|---|
default-src | Fallback for directives not otherwise set |
script-src | Allowed origins for JavaScript |
style-src | Allowed origins for stylesheets |
img-src | Image origins (often 'self' data:) |
connect-src | Targets for fetch, XHR, WebSocket, EventSource |
frame-ancestors | Who may embed the page in an iframe (anti-clickjacking) |
base-uri | Restricts the <base> tag, often 'self' |
object-src | Legacy plugins, best set to 'none' |
The frame-ancestors directive deserves a separate mention: it replaces the old X-Frame-Options header and controls who may display the page in a frame, which shuts down clickjacking attacks.
The ‘unsafe-inline’ trap: nonces and hashes
The most common temptation is to add 'unsafe-inline' to script-src so that scripts embedded in the HTML keep working. That keyword cancels most of the protection: it allows precisely the kind of script an attacker seeks to inject. Two mechanisms let you allow one specific inline script without opening the door to all others. A nonce is a random value, regenerated for every response, placed both in the header and on the tag.
<!-- Header: script-src 'nonce-r4Nd0mBase64' 'strict-dynamic' -->
<script nonce="r4Nd0mBase64">
// this specific block is allowed, no other inline is
</script>The hash ('sha256-…') follows the same logic for a script with fixed content. The 'strict-dynamic' keyword rounds out the setup: the trust granted to a nonced script propagates to the scripts it loads, which avoids maintaining a long list of domains.
Adding ‘unsafe-inline’ to script-src means allowing exactly the kind of code an XSS attack seeks to inject.
Rolling out without breaking production: Report-Only mode
Switching on a strict policy at once on an existing site almost always causes breakage: a tracking tag, an external font or a third-party widget ends up blocked. The Content-Security-Policy-Report-Only header solves this. It applies the policy in observation mode: the browser blocks nothing but reports each violation to a collection endpoint. The list of refused resources lets you tune the policy before enforcing it for real.
add_header Content-Security-Policy-Report-Only "default-src 'self'; report-uri /csp-report" always;Watch point: always start with Report-Only on a production site, let it run several days to cover every path, then switch to the enforcing header. Also check that the proxy or CDN does not rewrite the header, which would apply a different policy than intended.
Collecting and reading violation reports
The historical mechanism, report-uri, asks the browser to send a JSON document on each violation, describing the page involved, the breached directive and the blocked resource. The modern approach pairs the Reporting-Endpoints header with the report-to directive, better integrated with the browser’s Reporting API. A lightweight server route is enough to collect these submissions.
{
"csp-report": {
"document-uri": "https://example.com/account",
"violated-directive": "script-src 'self'",
"blocked-uri": "https://cdn.thirdparty.com/widget.js"
}
}Reading these reports regularly surfaces the third-party sources to allow explicitly: a font served from a CDN, an embedded map or video, an analytics script. Each becomes a deliberate line in the policy, never a blanket pass granted to a whole category.
A reasonable starting baseline
For a typical site served by Nginx, a restrictive but functional policy can serve as a base, to be widened directive by directive according to observation-mode reports.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'; upgrade-insecure-requests" always;The upgrade-insecure-requests directive asks the browser to convert residual HTTP requests to HTTPS, a useful net during a migration. Every added source should meet an identified need, never a block resolved out of convenience with 'unsafe-inline'.
The bottom line
CSP is a defence layer that limits the impact of an injection once everything else has failed. The method that holds rests on three principles: ban 'unsafe-inline' in favour of nonces or hashes, deploy first in Report-Only to map legitimate resources, then tighten directive by directive. A policy built in that order protects durably without turning every release into a hunt for regressions.
On the sites I maintain, I always run CSP in Report-Only for at least a week before making it enforcing, and I centralise the reports to review them calmly. It is tedious, but it is the only way I have found to avoid the classic scenario: a strict policy switched on a Friday evening, and the contact form dead by Monday. CSP is not a switch, it is a gradual setting. — Simon Janvier
Further reading
Reference for the directives and their syntax: Content-Security-Policy on MDN Web Docs.
