Content Security Policy in Practice
CSP as a safety net
The Securing Your API course introduced Content-Security-Policy: default-src 'self' as a security header. That was a starting point. This lesson shows how to build a CSP that actually works in a real app without breaking things.
Output encoding is the primary defense against XSS. CSP is the second layer — it limits the damage if an XSS bug slips through. Even if an attacker injects a <script> tag, CSP can prevent it from executing.
Why default-src 'self' breaks things
default-src 'self' means “only load resources from the same origin.” This blocks:
- Inline scripts (
<script>alert(1)</script>) - Inline event handlers (
<button onclick="...">) - Inline styles (
<div style="...">) - Scripts from CDNs (Google Fonts, analytics, etc.)
The inline script restriction is the useful part (it blocks XSS). The CDN restriction may or may not matter. But the inline event handler and style restrictions often break legitimate code.
Nonces: allowing specific inline scripts
A nonce (number used once) is a random value that you generate per-request and add to both the CSP header and the script tag. The browser only executes scripts whose nonce matches the CSP.
route.get("/app", {
resolve: (c) => {
const nonce = crypto.randomUUID();
const html = `
<html>
<head>
<script nonce="${nonce}">
// This script will execute because its nonce matches the CSP
console.log("Legitimate script");
</script>
</head>
<body>
<h1>My App</h1>
</body>
</html>
`;
return new Response(html, {
headers: {
"content-type": "text/html",
"content-security-policy": `default-src 'self'; script-src 'nonce-${nonce}'`,
},
});
},
}); The CSP says: “Only execute scripts with this specific nonce.” An attacker who injects <script>alert(1)</script> cannot include the nonce (they do not know it — it is different on every request), so the browser blocks the injected script.
strict-dynamic
With nonces, your legitimate scripts can load other scripts dynamically, and those loaded scripts also execute. The strict-dynamic keyword enables this:
script-src 'nonce-abc123' 'strict-dynamic' This means: “Trust scripts with this nonce, and trust any scripts that those scripts load.” This is useful when your app uses a bundler or dynamically loads modules.
Report-only mode
Deploying a new CSP can break things. Report-only mode lets you test without blocking:
headers.set(
"content-security-policy-report-only",
"default-src 'self'; script-src 'nonce-abc123'; report-uri /csp-report",
); The browser enforces the policy in report mode: it does not block anything, but it sends violation reports to /csp-report. You can monitor the reports, fix any legitimate code that would be blocked, and then switch to enforcement mode.
// Receive CSP violation reports
route.post("/csp-report", {
resolve: async (c) => {
const report = await c.request.json();
console.log("CSP violation:", JSON.stringify(report));
return new Response(null, { status: 204 });
},
}); A practical CSP for an API with some HTML pages
Our notes app is mostly a JSON API, but it has a few HTML-rendering routes. Here is a practical CSP:
onResponse: ({ request, response, locals }) => {
const headers = new Headers(response.headers);
// Only apply CSP to HTML responses
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("text/html")) {
headers.set(
"content-security-policy",
[
"default-src 'self'",
`script-src 'nonce-${locals.nonce}'`,
"style-src 'self' 'unsafe-inline'", // Allow inline styles (common in apps)
"img-src 'self' https:", // Allow images from HTTPS sources
"connect-src 'self'", // Restrict fetch/XHR to same origin
"frame-ancestors 'none'", // Prevent framing (like X-Frame-Options)
].join("; "),
);
}
return new Response(response.body, { status: response.status, headers });
}, Exercises
Exercise 1: Add the nonce-based CSP to your HTML route. Inject a <script> tag via a note title. The browser should block it (check the browser console for a CSP violation message).
Exercise 2: Switch to report-only mode. The injected script now executes, but the browser sends a violation report. Check the console for the report.
Exercise 3: Try adding a legitimate inline script without a nonce. The CSP should block it. Add the nonce to the script tag and verify it works.
Why must the CSP nonce be different on every request?