CSP (Content Security Policy): The Header That Blocks XSS at the Source
Prologue: The Second Line of Defense Against XSS
The first line of XSS defense is input escaping — converting <, >, " in user input so injected scripts can't execute.
But it's not perfect. A third-party library might have a vulnerability. Legacy code might have dangerouslySetInnerHTML lurking somewhere. A rich text editor might sanitize incompletely. Countless ways a XSS payload can slip into the HTML.
CSP is the second line of defense: "the payload got injected — but it won't execute."
Analogy: first line is "don't let bad things in." CSP is "even if they got in, they can't operate."
What CSP Does
CSP is an HTTP response header. It tells the browser which resources, from which sources, are allowed to load on the page.
Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted-cdn.com
With this header, the browser:
- Executes only scripts from
self (same origin)
- Executes scripts from
https://trusted-cdn.com
- Blocks everything else — including inline scripts
<!-- Allowed -->
<script src="/app.js"></script>
<script src="https://trusted-cdn.com/lib.js"></script>
<!-- Blocked (inline script) -->
<script>alert('XSS')</script>
<!-- Blocked (unauthorized external domain) -->
<script src="https://evil.com/malware.js"></script>
XSS attacks inject scripts into the page. CSP stops those scripts from executing.
Directive Syntax
CSP is composed of directives, each controlling a specific resource type.
default-src
Fallback for any directive not explicitly set.
Content-Security-Policy: default-src 'self'
script-src
Controls JavaScript execution sources.
# Own origin + specific CDNs only
script-src 'self' https://cdn.jsdelivr.net https://www.googletagmanager.com
# unsafe-inline: allows inline scripts (weakens XSS protection — avoid)
script-src 'self' 'unsafe-inline'
# unsafe-eval: allows eval() (more dangerous — never use in production)
script-src 'self' 'unsafe-eval'
style-src
Controls CSS sources.
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com
img-src
Controls image sources.
# data: URI for base64 inline images
img-src 'self' data: https://images.example.com
connect-src
Controls fetch, XHR, WebSocket.
connect-src 'self' https://api.example.com wss://ws.example.com
frame-ancestors
Controls who can embed this page in an iframe (clickjacking protection).
frame-ancestors 'none' # No iframe embedding allowed
frame-ancestors 'self' # Only same-origin iframes
Full CSP Example
Content-Security-Policy:
default-src 'self';
script-src 'self' https://www.googletagmanager.com;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
img-src 'self' data: https://images.example.com;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
form-action 'self';
base-uri 'self';
object-src 'none'
Nonce-Based Approach
unsafe-inline allows inline scripts, weakening XSS protection. But sometimes inline scripts are unavoidable. That's where nonces come in.
How It Works
The server generates a random nonce per request, placing it in both the CSP header and the inline script tag. The browser only executes scripts whose nonce matches the CSP header.
Even if an attacker injects <script> via XSS, they don't know the nonce — it's random per request, unpredictable.
// Next.js middleware — generate nonce per request
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
style-src 'self' 'nonce-${nonce}';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`.replace(/\s{2,}/g, " ").trim();
const response = NextResponse.next();
response.headers.set("Content-Security-Policy", cspHeader);
response.headers.set("x-nonce", nonce);
return response;
}
// Next.js Server Component — read and use nonce
import { headers } from "next/headers";
import Script from "next/script";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const nonce = (await headers()).get("x-nonce") ?? "";
return (
<html>
<head>
{/* Inline script with nonce passes CSP */}
<script nonce={nonce} dangerouslySetInnerHTML={{
__html: `window.__INITIAL_DATA__ = ${JSON.stringify(initialData)}`,
}} />
</head>
<body>
{children}
<Script
src="https://www.googletagmanager.com/gtag/js"
strategy="afterInteractive"
nonce={nonce}
/>
</body>
</html>
);
}
strict-dynamic
Combined with a nonce, strict-dynamic allows scripts loaded dynamically by nonced scripts to also execute — without needing origin-based whitelists.
script-src 'nonce-{RANDOM}' 'strict-dynamic'
# 'self' and URL sources are ignored when strict-dynamic is present
Hash-Based Approach
Nonces suit dynamic pages. For static inline scripts, hashes work too.
import crypto from "crypto";
const inlineScript = `console.log("Hello, World!")`;
const hash = crypto.createHash("sha256").update(inlineScript).digest("base64");
// CSP: script-src 'sha256-{hash}'
<!-- This exact script is allowed if its hash is in CSP -->
<script>console.log("Hello, World!")</script>
If the script changes even slightly, the hash changes and CSP must be updated. For frequently changing scripts, nonces are more practical.
Report-Only Mode: Safe Testing Before Enforcing
Applying CSP immediately can break existing scripts. Use Report-Only first to collect violations.
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; report-uri /csp-report
// Collect violation reports
app.post("/csp-report", express.json({ type: "application/csp-report" }), (req, res) => {
const report = req.body["csp-report"];
console.warn("CSP Violation:", {
documentUri: report["document-uri"],
violatedDirective: report["violated-directive"],
blockedUri: report["blocked-uri"],
sourceFile: report["source-file"],
lineNumber: report["line-number"],
});
res.status(204).end();
});
Collect violations, build your whitelist, then switch to enforcing mode.
Next.js CSP Full Setup
// next.config.ts
const nextConfig: NextConfig = {
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "Content-Security-Policy",
value: [
"default-src 'self'",
"script-src 'self' https://www.googletagmanager.com",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self' https://api.example.com",
"frame-ancestors 'none'",
"form-action 'self'",
"object-src 'none'",
"base-uri 'self'",
"upgrade-insecure-requests",
].join("; "),
},
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
],
},
];
},
};
// middleware — dynamic nonce-based CSP
export default async function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const csp = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https:`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: https:`,
`font-src 'self' https://fonts.gstatic.com`,
`connect-src 'self' https://api.example.com`,
`frame-ancestors 'none'`,
`object-src 'none'`,
`base-uri 'self'`,
`upgrade-insecure-requests`,
].join("; ");
const response = intlMiddleware(request);
response.headers.set("Content-Security-Policy", csp);
response.headers.set("x-nonce", nonce);
return response;
}
Common Pitfalls
Pitfall 1: Defaulting to unsafe-inline
# This largely defeats the purpose of CSP
script-src 'self' 'unsafe-inline' 'unsafe-eval'
unsafe-inline means injected <script> tags work. Use nonces instead.
Pitfall 2: Wildcards Too Broad
# Too loose
script-src *
# Better
script-src 'self' https://cdn.example.com
Pitfall 3: Third-Party Scripts
Google Analytics, Intercom, Hotjar — many load additional scripts dynamically or use inline styles.
# GTM requires this
script-src 'self' https://www.googletagmanager.com 'nonce-{RANDOM}' 'strict-dynamic'
With strict-dynamic, GTM-loaded scripts work without individually whitelisting each URL.
Pitfall 4: Libraries That Use eval
Some bundlers and template engines call eval().
EvalError: Refused to evaluate a string as JavaScript
because 'unsafe-eval' is not an allowed source...
Check if this is only in development mode (webpack uses eval for source maps in dev). Switch to devtool: 'source-map' for production builds.
Gradual Rollout Strategy
Step 1: Report-Only for 2-4 weeks
Content-Security-Policy-Report-Only: default-src 'self'; ...; report-uri /csp-report
Step 2: Analyze violations → add required sources to whitelist
Step 3: Apply loose CSP in enforcement mode
script-src 'self' 'unsafe-inline' ...
Step 4: Remove unsafe-inline → migrate to nonce approach
Step 5: Add strict-dynamic to tighten further
This sequence prevents sudden feature breakage.
Testing Tools
Mozilla Observatory — rates CSP and all security headers
https://observatory.mozilla.org
CSP Evaluator (Google) — checks for weaknesses in your policy
https://csp-evaluator.withgoogle.com
Security Headers — scans and grades your response headers
https://securityheaders.com
What CSP Doesn't Cover
CSP is powerful but not a silver bullet.
- CSRF: CSP blocks script execution, but form submissions and image-based CSRF need other defenses (SameSite cookies, CSRF tokens).
- SQL Injection: Backend vulnerabilities aren't fixed by frontend headers.
- Clickjacking:
frame-ancestors helps, but also set X-Frame-Options as belt-and-suspenders.
Takeaway
CSP setup is complex and debugging can be painful, but it's the strongest browser-level defense against XSS.
Key points:
- Avoid
unsafe-inline and unsafe-eval — use nonces
- Report-Only mode first, then enforce incrementally
- Next.js middleware enables dynamic nonce generation per request
strict-dynamic eliminates origin-based whitelists for script-loaded scripts
Input escaping + CSP together create a robust two-layer defense against XSS.