Content Security Policy (CSP)
Problem
You sanitize user input. You encode output. You review every pull request. And then one day a markdown renderer, a templating edge case, 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, exfiltrate session tokens, rewrite the login form to point at an attacker’s server, and call any API the user is authenticated against. Cross-site scripting is not one bug you fix once; it is a class of bug that reappears every time someone forgets to escape a value.
The attack surface is wider than your own code. Most applications pull JavaScript from CDNs, analytics providers, tag managers, and ad networks. Any one of those origins becoming compromised means malicious code is served straight into your users’ browsers, and your sanitization never gets a chance to look at it. Inline scripts make it worse: when legitimate inline code is normal on your page, the browser has no way to tell your <script>onClick()</script> apart from an attacker’s injected one. They look identical because they are identical in form.
What you need is a defense that does not depend on catching every injection by hand. You need the browser itself to enforce an allowlist of where executable code is permitted to come from, so that even a successful injection has nowhere to load from and nothing to run.
Solution
Content Security Policy is an HTTP response header that hands the browser a set of rules describing which sources may load which kinds of resources. The browser enforces those rules natively, blocking anything that falls outside them, regardless of how it got onto the page. Because the policy lives in a header rather than in your application logic, it acts as a layer of defense-in-depth that keeps working even after every other defense has failed.
Start from a deny-by-default posture with default-src 'self', which restricts all resource types to your own origin unless a more specific directive overrides it. Then loosen it precisely with per-resource directives: script-src for JavaScript, style-src for CSS, img-src for images, connect-src for fetch, XHR, and WebSocket targets, and so on. Each directive names the exact origins you trust, so an injected script pointing at evil.example simply never loads.
The most valuable thing CSP does is let you forbid inline scripts and eval. Avoid 'unsafe-inline' and 'unsafe-eval' in script-src; their presence effectively disables CSP’s protection against injected scripts. When you genuinely need an inline script, authorize that specific block with a nonce (a random value regenerated on every response) or a hash of the script’s contents. The browser runs only inline scripts whose nonce or hash matches the policy, so an attacker who cannot guess your per-request nonce cannot get their inline code to execute.
CSP can also report on itself. The report-to directive (and the older report-uri) tells the browser to POST a JSON description of every violation to an endpoint you control, which surfaces both real attacks and accidental misconfigurations. Roll a new policy out with the Content-Security-Policy-Report-Only header first: in report-only mode the browser logs violations without blocking anything, so you can watch the reports, tune the directives against real traffic, and switch to the enforcing header only once the noise has stopped.
Example
These examples build a policy from a starter header, set it on the server, authorize a single inline script with a nonce, and wire up violation reporting.
A starter policy
A reasonably strict baseline locks everything to your own origin, names the one CDN you trust for scripts, and forbids the legacy escape hatches. Note that the full header is a single line; it is wrapped here for readability.
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' disables plugins like Flash, base-uri 'self' stops injected <base> tags from hijacking relative URLs, and frame-ancestors 'none' prevents your page from being embedded in a frame for clickjacking. Each script-src and connect-src origin is one you have deliberately chosen to trust.
Setting the header on the server
The header is just an HTTP response header, so any server or middleware can emit it. In Express, Helmet generates a sensible policy and lets you override 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'"],
},
})
);
Without Helmet, you can set the header directly with res.setHeader("Content-Security-Policy", policyString), building policyString from the same directives.
Allowing specific inline scripts with a nonce
When you must keep an inline script, generate a fresh random nonce per request, add it to the script-src directive, and stamp the same value onto the <script> tag. Only the tag carrying 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 must be unpredictable and unique to each response. Reusing a static nonce across requests defeats the protection entirely, because an attacker could simply copy it.
Reporting violations
Point the browser at a reporting endpoint so you see violations as they happen. The modern approach uses report-to together with a Reporting-Endpoints header; report-uri remains widely supported 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 enforcing, deploy the identical policy in report-only mode so violations are logged but nothing is blocked:
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; report-to csp-endpoint
Watch the reports for a few days, add any legitimate origins your real traffic reveals, and only then switch the header name to Content-Security-Policy to start enforcing.
Benefits
- Injected scripts are blocked at the browser level even when input sanitization and output encoding miss something, turning XSS from a full compromise into a non-event.
- A compromised CDN or third-party provider cannot serve executable code into your page unless its origin is on your allowlist.
- Forbidding
'unsafe-inline'and'unsafe-eval'closes off the two most common vectors injected scripts rely on to execute. - Nonces and hashes let you keep the inline scripts you actually need without weakening the policy for everything else.
- Violation reports surface both active attacks and accidental misconfigurations, giving you visibility you would otherwise lack.
- The protection lives in a header and is enforced by the browser, so it works independently of your application code and across your entire site at once.
- Report-only mode lets you validate a policy against real traffic before it can break anything.
Tradeoffs
- Authoring a strict policy that does not break legitimate functionality is genuinely hard; expect several rounds of tuning against real traffic before you can enforce.
- Existing inline scripts, inline event handlers, and
eval-based libraries will break under a strict policy and must be refactored, nonced, or hashed. - Third-party scripts, analytics, tag managers, and ad networks often assume inline code or load from many origins, fighting against a tight allowlist.
- Nonce-based policies require plumbing a fresh random value through every response and into every inline tag, which couples your server rendering to your CSP.
- CSP mitigates injection but does not prevent it; it is no substitute for proper output encoding and input validation, only a backstop for when those fail.
- Directive support and behavior vary across browsers and CSP levels, so newer features may need a fallback (for example
report-urialongsidereport-to). - An overly broad policy full of
https:wildcards or'unsafe-inline'looks like protection while providing almost none, giving a false sense of security. - Reporting endpoints can generate significant noise from browser extensions and unrelated origins, requiring filtering before the signal is useful.
Summary
Content Security Policy is a browser-enforced allowlist, delivered as an HTTP header, that defines where scripts, styles, and other resources may come from and blocks everything else. Start from default-src 'self', forbid 'unsafe-inline' and 'unsafe-eval', authorize the inline scripts you need with per-request nonces, and roll changes out in report-only mode before enforcing. It is a defense-in-depth layer that keeps XSS contained even when your other defenses fail, not a replacement for encoding and validation.