The problem
You sanitize user input and encode output, and then one day a markdown renderer or a third-party widget lets a single <script> slip through, and that script runs with the full privileges of your page. It can read the DOM, send session tokens to an attacker, change the login form so it submits to the attacker’s server, and call any API the user is signed in to. Cross-site scripting comes back each time someone forgets to escape a value, so fixing one bug doesn’t make the problem go away.
The pattern
Content Security Policy is an HTTP response header that gives the browser a set of rules about where each kind of resource is allowed to load from. The browser enforces those rules itself and blocks anything that breaks them, however it got onto the page. The policy is set in a header instead of in your application code, so it’s an extra layer of defense that keeps working when your other defenses fail.
Problem
Your own code isn’t the only risk, either. Most applications load JavaScript from CDNs, analytics providers, tag managers, and ad networks. If any one of those origins is compromised, it can send malicious code straight to your users’ browsers, and your sanitization never runs on that code. Inline scripts make this worse. If your page already uses legitimate inline code, the browser has no way to tell your <script>onClick()</script> apart from one an attacker injected, because the two look exactly the same in the HTML.
You need a defense that doesn’t depend on catching every injection manually. If the browser itself enforces an allowlist of the places scripts are allowed to come from, an injected script still can’t load or run, even when the injection works.
Solution
A policy usually starts by blocking everything by default with default-src 'self', which only allows resources from your own origin unless a more specific directive overrides it. Directives for each kind of resource then loosen it where you need to, such as script-src for JavaScript, style-src for CSS, img-src for images, and connect-src for fetch, XHR, and WebSocket requests. Each directive lists the exact origins you trust, so the browser blocks an injected script that tries to load code from evil.example.
Most of the protection you get from CSP comes from being able to forbid inline scripts and eval. If you allow 'unsafe-inline' or 'unsafe-eval' in script-src, you’ve mostly disabled CSP’s protection against injected scripts. If you do need an inline script, you can allow that one script with a nonce or a hash. A nonce is a random value that the server generates fresh for each response, and a hash is calculated from the script’s contents. The browser only runs inline scripts whose nonce or hash matches the policy, so an attacker who can’t guess the nonce for that request can’t get their inline code to run.
CSP can also tell you when something breaks the policy. The report-to directive, or the older report-uri, tells the browser to send a JSON description of each violation in a POST request to an endpoint you control, so you find out about real attacks and about mistakes in your own configuration. Teams usually release a new policy with the Content-Security-Policy-Report-Only header first. In report-only mode, the browser reports violations without blocking anything, so you can adjust the directives against real traffic, and switch to the enforcing header once you’ve stopped seeing unexpected violations.
Example
The examples below start with a basic policy, then set it on the server, allow a single inline script with a nonce, and set up violation reporting.
A starter policy
A reasonably strict policy to start from only allows resources from your own origin, plus the one CDN you trust for scripts. The full header is a single line, even where it wraps on screen.
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self'; img-src 'self' data: https:; connect-src 'self' https://api.example.com; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
object-src 'none' blocks <object> and <embed> content, base-uri 'self' stops an injected <base> tag from changing where your relative URLs go, and frame-ancestors 'none' stops other sites from embedding your page in a frame, which protects against clickjacking. The origins in script-src and connect-src are ones you’ve deliberately chosen to trust.
Setting the header on the server
CSP is an ordinary HTTP response header, so any server or middleware can send it. In Express, the Helmet middleware sets a sensible policy, and you can override individual directives.
import express from "express";
import helmet from "helmet";
const app = express();
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://cdn.example.com"],
styleSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.example.com"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
},
})
);
If you’re not using Helmet, you can set the header directly with res.setHeader("Content-Security-Policy", policyString), where policyString is built from the same directives.
Allowing specific inline scripts with a nonce
To keep an inline script, the server generates a fresh random nonce for each request, adds it to the script-src directive, and puts the same value on the <script> tag. Only a script tag with the matching nonce is allowed to run.
import crypto from "node:crypto";
app.use((req, res, next) => {
res.locals.nonce = crypto.randomBytes(16).toString("base64");
res.setHeader(
"Content-Security-Policy",
`default-src 'self'; script-src 'self' 'nonce-${res.locals.nonce}'`
);
next();
});
<!-- The nonce attribute must match the value in the CSP header for this request -->
<script nonce="r4nd0mPerRequestValue==">
window.__APP_CONFIG__ = { theme: "dark" };
</script>
The nonce should be unpredictable and different for each response. If you reuse the same nonce across requests, an attacker could copy it into their injected script, and the nonce would no longer protect anything.
Reporting violations
With a reporting endpoint, you find out about violations as they happen. The newer approach is report-to together with a Reporting-Endpoints header, and report-uri is still widely supported, so it works as a fallback for older browsers.
Reporting-Endpoints: csp-endpoint="https://example.com/csp-reports"
Content-Security-Policy: default-src 'self'; script-src 'self'; report-to csp-endpoint; report-uri /csp-reports
Before you enforce the policy, you can run it in report-only mode, so the browser reports violations without blocking anything.
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; report-to csp-endpoint
After a few days of reports, you can add any legitimate origins that show up in them, and then change the header name to Content-Security-Policy to start enforcing the policy.
Benefits
- The browser blocks injected scripts even when input sanitization and output encoding miss something, which limits the damage an XSS bug can do.
- A compromised CDN or third-party provider can’t serve executable code into your page unless its origin is on your allowlist.
- Forbidding
'unsafe-inline'and'unsafe-eval'blocks two of the main ways injected scripts get to run. - Nonces and hashes let you keep the inline scripts you need without weakening the policy for everything else.
- Violation reports tell you about active attacks and accidental misconfigurations that you wouldn’t otherwise find out about.
- The protection is set in a header and enforced by the browser, so it works separately from your application code and covers your whole site at once.
- Report-only mode lets you test a policy against real traffic before it can break anything.
Tradeoffs
- Writing a strict policy that doesn’t break legitimate functionality is hard, and it usually takes several rounds of adjusting it against real traffic before you can enforce it.
- Under a strict policy, existing inline scripts need a nonce or a hash, and inline event handlers and libraries that use
evalstop working until you refactor them. - Third-party scripts, analytics, tag managers, and ad networks often use inline code or load from many origins, which makes a tight allowlist hard to maintain.
- With a nonce-based policy, your server has to generate a fresh random value for each response and add it to each inline script tag, which ties your server rendering to your CSP.
- CSP limits the damage from injection but doesn’t stop the injection itself, so you still need proper output encoding and input validation.
- Directive support and behavior vary across browsers and CSP levels, so newer features may need a fallback, such as
report-urialongsidereport-to. - A broad policy full of
https:wildcards or'unsafe-inline'gives you almost no protection, but it can still make the team feel safe. - Your reporting endpoint can receive a lot of irrelevant reports caused by browser extensions and unrelated origins, so you’ll need to filter those out before the reports are useful.
Summary
Content Security Policy is an allowlist that you send in an HTTP header and the browser enforces. It lists where scripts and other resources can come from, and the browser blocks everything else. A good policy starts from default-src 'self', forbids 'unsafe-inline' and 'unsafe-eval', and allows the inline scripts you need with a fresh nonce for each request. Teams usually run it in report-only mode before they enforce it. CSP limits the damage XSS can do when your other defenses miss something, and you’d use it alongside output encoding and input validation.
See also
- Subresource Integrity — Security
- Trusted Types — Security
- CSRF Protection — Security
- Output Encoding — Security
- Content Security Policy - MDN
- CSP Quick Reference - CSP Reference
- CSP Best Practices - web.dev