Content Security Policy (CSP) Tutorial: Stop XSS in 20 Minutes
A single missed escape() call is all it takes for an attacker to run their own JavaScript inside your users' browsers. Here's how a Content Security Policy shuts that door โ explained with real headers you can copy, paste, and actually ship today.
 Tutorial Stop XSS in 20 Minutes.webp)
Why XSS Still Wrecks Production Apps in 2026
Cross-site scripting has been on the OWASP radar for over two decades, and it still shows up in disclosure reports every week. The pattern is always the same: user-controlled data โ a comment, a profile bio, a search query, a URL parameter โ ends up rendered as HTML or executed as JavaScript without being sanitized first. Once that happens, an attacker's script runs with the same privileges as your own code. It can read cookies, steal session tokens, submit forms on the user's behalf, or quietly redirect them to a phishing page.
Input sanitization and output encoding are still your first line of defense, and no security header replaces careful coding. But developers are human, frameworks have edge cases, and third-party scripts you don't fully control get embedded into your pages every day. Content Security Policy exists for exactly that gap: it's a second, browser-enforced layer that stops injected scripts from running even when something slips past your sanitization.
What CSP Actually Does
A Content Security Policy is an HTTP response header (or a <meta> tag, though the header is preferred) that tells the browser exactly which sources of content your page is allowed to load and execute. Instead of trusting every script tag that ends up in the DOM, the browser checks each one against an allowlist you define. Anything that doesn't match โ an inline script injected by an attacker, a rogue <img> pointed at a data-exfiltration endpoint, a malicious stylesheet โ simply refuses to load.
Think of it less like a firewall and more like a bouncer with a guest list. Your policy is the list. The browser is the bouncer standing at every single resource request checking IDs.
The Directives You'll Actually Use
CSP has dozens of directives, but in practice you'll lean on a small, predictable set. Here's the shortlist worth memorizing:
| Directive | Controls |
|---|---|
| default-src | Fallback for any directive you don't explicitly set |
| script-src | Where JavaScript is allowed to load from โ the directive that stops most XSS |
| style-src | Allowed sources for stylesheets and inline style blocks |
| img-src | Allowed sources for images, including data URIs |
| connect-src | Where fetch, XHR, WebSocket, and EventSource requests can go |
| frame-ancestors | Who is allowed to embed your page in an iframe โ your clickjacking defense |
| object-src | Plugins like Flash and legacy embeds โ almost always set to 'none' |
| base-uri | Restricts what the page's <base> tag can be set to |
| report-uri / report-to | Where the browser sends violation reports |
Writing Your First Real Policy
Start restrictive and loosen only where you have to. A reasonable starting policy for a typical app that serves its own scripts and styles, loads images from a CDN, and calls its own API looks like this:
Content-Security-Policy:
default-src 'self';
script-src 'self' https://cdn.example.com;
style-src 'self' 'unsafe-inline';
img-src 'self' https://images.example.com data:;
connect-src 'self' https://api.example.com;
font-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';Read it directive by directive: everything defaults to same-origin only (default-src 'self'), scripts can additionally come from a trusted CDN, images can come from a separate image host or be inlined as data URIs, API calls are locked to your own backend, plugins are banned outright, and no other site is allowed to iframe your page.
Inline Scripts: The Part That Trips Everyone Up
Most real-world CSP rollouts stall on one problem: analytics snippets, style-jsx, or a template engine that injects inline <script> or <style> blocks directly into the HTML. By default, CSP blocks all inline scripts and styles โ that's the whole point, since inline injection is exactly how most XSS payloads execute. You have three honest options:
- Move the code to external files. The cleanest fix. If it's not inline, CSP doesn't need to make an exception for it.
- Use a nonce. Generate a random, unique value on every server response, add it to your policy as
script-src 'nonce-RANDOM123', and stamp the same value onto each legitimate<script nonce="RANDOM123">tag. An attacker injecting a script has no way to guess the nonce, so their payload gets blocked while your own inline code runs fine. - Use a hash. For static inline scripts that never change, you can compute a SHA-256 hash of the exact script content and allowlist that hash instead. Any modification to the script โ including an attacker's injection โ produces a different hash and gets rejected.
Avoid reaching for 'unsafe-inline' as a shortcut. It works, but it also defeats the primary reason you're adding CSP in the first place โ it tells the browser to allow inline scripts unconditionally, injected ones included.
Rolling It Out Without Breaking Production
Don't ship an enforcing policy to production on day one. CSP has a report-only mode built specifically for this: it logs violations without blocking anything, so you can see what would have broken before it actually does.
Content-Security-Policy-Report-Only:
default-src 'self';
script-src 'self';
report-uri /csp-violation-report;A practical rollout usually looks like this:
- Deploy in report-only mode with a reporting endpoint wired up.
- Watch violation reports for a few days to a couple of weeks across real traffic.
- Fix legitimate violations โ third-party widgets, inline handlers, forgotten CDNs โ one at a time.
- Switch the header from report-only to enforcing once reports go quiet.
- Keep the reporting endpoint active permanently to catch regressions and real attacks alike.
Setting the Header in a Next.js App
Since you're likely serving this from Next.js, the cleanest place to set CSP is next.config.js, so it applies at the edge before any page code runs:
// next.config.js
const cspHeader = [
"default-src 'self'",
"script-src 'self' 'strict-dynamic'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"connect-src 'self'",
"font-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'none'",
"upgrade-insecure-requests",
].join("; ");
module.exports = {
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "Content-Security-Policy",
value: cspHeader,
},
],
},
];
},
};For per-request nonces with the App Router, generate the nonce in middleware, attach it to the response headers, and read it back in your root layout to stamp onto any inline scripts you still need. Next.js's own documentation covers the middleware-based nonce pattern in detail if your app needs that level of strictness.
Common Mistakes That Undercut a Policy
- Wildcarding script-src. Something like
script-src *technically satisfies the header requirement while providing zero protection. - Forgetting object-src. Leaving it unset lets legacy plugin-based attacks slip through even when script-src looks solid.
- Skipping frame-ancestors. CSP is also your modern replacement for
X-Frame-Options, and it's easy to forget when you're focused only on XSS. - Never revisiting the policy. New third-party embeds get added over time, and a stale, overly permissive policy accumulates the same way unused npm dependencies do.
- Testing only in one browser. Directive support has improved a lot, but always verify behavior across the browsers your actual users run.
Verifying Your Policy
Once your header is live, check it with your browser's DevTools console โ CSP violations show up there clearly, listing the blocked resource and the directive that stopped it. For a broader audit, Google's CSP Evaluator and Mozilla's Observatory both scan a live URL and flag weak or overly permissive directives you might have missed.
The Takeaway
CSP won't catch every possible attack, and it's not a substitute for validating and encoding user input properly. What it does give you is a hard backstop: even when a script somehow gets injected into your page, the browser itself refuses to run it unless it comes from a source you've explicitly trusted. Twenty minutes writing a policy, testing it in report-only mode, and flipping it to enforcing is a small price for closing off one of the most common attack paths on the web today.
Continue your journey
You're doing great. Keep building your financial foundation with these guides: