What is Content-Security-Policy?
Content-Security-Policy (CSP) is an HTTP response header that tells the browser which content sources it should consider trusted. Every resource load — scripts, stylesheets, images, fonts, frames, media — can be controlled per-type.
Without CSP, an attacker who finds a single XSS vector can inject arbitrary JavaScript that runs with full access to cookies, localStorage, the DOM, and any API tokens the user holds. CSP shrinks the blast radius of an XSS to nothing — or at worst, to the specific sources you've explicitly allowed.
Why not just fix the XSS?
You should fix XSS bugs. CSP is defense-in-depth, not a substitute.
The problem is scale: modern web applications load dozens of third-party scripts, and any one of them could introduce an XSS vector you don't control. A compromised npm package, a malicious ad, a supply-chain attack on a CDN you trust — CSP limits the damage in all of these cases even when the XSS itself can't be fixed immediately.
Browsers with a strict CSP will refuse to execute injected scripts even when the injection succeeds.
How CSP works
The browser enforces CSP before executing any resource. When it encounters a script, style, or image request, it checks the CSP against:
- The scheme (
https:,data:,blob:) - The hostname (
cdn.example.com) - The full URL path (where specified)
- Whether inline code is allowed
- Whether a nonce or hash matches
If the resource doesn't match any allowed source, the browser blocks it. With report-uri or report-to configured, it also sends a violation report to your endpoint.
Directive reference
| Directive | Controls |
|---|---|
default-src | Fallback for any fetch directive not explicitly set |
script-src | JavaScript sources: <script> tags, event handlers, javascript: URLs |
style-src | Stylesheets: <style> tags, style= attributes, <link rel=stylesheet> |
img-src | Image sources: <img>, CSS background-image, favicons |
font-src | Web fonts: @font-face sources |
connect-src | fetch(), XMLHttpRequest, WebSocket, EventSource |
media-src | <audio>, <video> |
object-src | <object>, <embed>, <applet> — set to 'none' always |
frame-src | <iframe> sources |
frame-ancestors | Which origins can embed your page in a frame (replaces X-Frame-Options) |
form-action | Where forms can submit — prevents phishing via form hijacking |
base-uri | Restricts <base href> — prevents base tag injection |
upgrade-insecure-requests | Upgrades http:// subresource requests to https:// automatically |
worker-src | Service workers, shared workers |
manifest-src | Web app manifests |
Source values
| Value | Meaning |
|---|---|
'self' | Same origin (scheme + hostname + port) |
'none' | No sources — block everything for this directive |
'unsafe-inline' | Allow inline code — voids XSS protection for that directive |
'unsafe-eval' | Allow eval(), Function(), setTimeout(string) — avoid if possible |
'unsafe-hashes' | Allow specific inline event handlers by hash |
'nonce-<base64>' | Allow a specific inline script/style that carries this nonce |
'sha256-<hash>' | Allow an inline block that matches this SHA-256 hash |
'strict-dynamic' | Trust scripts loaded by already-trusted scripts (nonce-propagating) |
https: | Any HTTPS source |
data: | Data URIs (restrict to img-src only) |
blob: | Blob URLs |
example.com | Exact hostname (all paths, http + https) |
*.example.com | Wildcard subdomain |
unsafe-inline in script-src defeats CSP for XSS protection entirely. A policy with script-src 'self' 'unsafe-inline' provides zero XSS protection — an attacker can inject any inline script. If you need it for a legacy reason, add it to style-src only, never script-src.
Common misconfigurations
1. unsafe-inline in script-src
The single most common CSP mistake. Usually added because an existing inline script breaks with CSP:
# WRONG — provides no XSS protection
Content-Security-Policy: script-src 'self' 'unsafe-inline'Solution: switch to nonces (see below) or move inline scripts to external files.
2. Wildcarding CDN domains
# WRONG — attacker can host malicious JS at cdn.jsdelivr.net/evil.js
Content-Security-Policy: script-src 'self' cdn.jsdelivr.netIf you're loading a specific library from a CDN, allow the specific path or switch to a hash or nonce.
3. Missing object-src
# WRONG — falls back to default-src, which often allows 'self'
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-abc'<object> and <embed> tags can load Flash/Java plugins that execute code outside CSP. Always set object-src 'none'.
4. Missing base-uri
Base tag injection (<base href="https://attacker.com">) redirects all relative URLs to the attacker's server, including script sources. Set base-uri 'self' or base-uri 'none'.
5. Missing frame-ancestors
CSP's frame-ancestors directive is the correct way to prevent clickjacking — X-Frame-Options is a legacy header that CSP supersedes. If you don't set frame-ancestors, you need X-Frame-Options separately.
6. report-only and never enforcing
CSP has a Content-Security-Policy-Report-Only mode that logs violations without blocking. This is useful during rollout, but many teams set it and forget it. Reports without enforcement provide zero protection.
Nonce-based CSP (recommended)
A nonce is a random base64 value generated per request. Add it to your script-src directive and to every inline <script> tag. The browser only executes scripts that carry a matching nonce. Injected scripts (XSS) don't know the nonce, so they can't execute.
How nonces work
Content-Security-Policy: script-src 'self' 'nonce-r4nd0mb4se64=='<!-- This executes — nonce matches -->
<script nonce="r4nd0mb4se64==">
console.log('trusted');
</script>
<!-- This is blocked — no matching nonce -->
<script>alert('xss')</script>The nonce must be:
- Cryptographically random (use
crypto.randomUUID()orcrypto.getRandomValues()) - Generated fresh per HTTP response — never reused or cached
- At least 128 bits of entropy (a UUID encoded as base64 is fine)
Next.js implementation
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const csp = [
`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`,
].join('; ');
const response = NextResponse.next({
request: { headers: new Headers(request.headers) },
});
response.headers.set('Content-Security-Policy', csp);
response.headers.set('x-nonce', nonce); // pass to layout
return response;
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};// app/layout.tsx
import { headers } from 'next/headers';
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const headersList = await headers();
const nonce = headersList.get('x-nonce') ?? '';
return (
<html lang="en">
<body>
<script nonce={nonce}>{`window.__NONCE__='${nonce}'`}</script>
{children}
</body>
</html>
);
}'strict-dynamic' explained
'strict-dynamic' propagates trust to scripts dynamically loaded by a nonce-trusted script. This allows bundlers (webpack, Turbopack) to split code without requiring you to nonce every chunk:
script-src 'nonce-abc123' 'strict-dynamic'With strict-dynamic, your nonce covers the root bundle, and the root bundle's dynamic imports inherit that trust automatically.
'strict-dynamic' ignores 'self' and 'unsafe-inline' in browsers that support it, making them no-ops. Include them as fallbacks for older browsers that don't support strict-dynamic.
Express implementation
// app.js
const helmet = require('helmet');
app.use((req, res, next) => {
const nonce = require('crypto').randomBytes(16).toString('base64');
res.locals.nonce = nonce;
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", `'nonce-${nonce}'`, "'strict-dynamic'"],
styleSrc: ["'self'", `'nonce-${nonce}'`],
imgSrc: ["'self'", 'data:', 'blob:'],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
frameAncestors: ["'none'"],
upgradeInsecureRequests: [],
},
})(req, res, next);
});Hash-based CSP
If you have a small number of static inline scripts that never change, you can use SHA-256 hashes instead of nonces:
# Generate the hash of your inline script content
echo -n "console.log('hello')" | openssl dgst -sha256 -binary | openssl base64
# → YWJjMTIz...Content-Security-Policy: script-src 'self' 'sha256-YWJjMTIz...'Hashes work for static content but don't scale to dynamic scripts. Use nonces for those.
CSP violation reporting
Configure a reporting endpoint to receive violation reports without blocking:
# Enforce + report
Content-Security-Policy: default-src 'self'; report-uri /csp-report
# Report only — no blocking (use during rollout)
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-reportA violation report looks like:
{
"csp-report": {
"document-uri": "https://example.com/page",
"violated-directive": "script-src-elem",
"blocked-uri": "https://evil.com/xss.js",
"source-file": "https://example.com/page",
"line-number": 42,
"original-policy": "default-src 'self'"
}
}Use a service like report-uri.com or build a simple endpoint that logs to your observability stack.
Rollout strategy
- Start in report-only mode — deploy
Content-Security-Policy-Report-Onlywith a strict policy and collect violations for 1–2 weeks - Fix legitimate violations — inline scripts, missing CDN domains, eval() calls in dependencies
- Switch to enforcement — change the header to
Content-Security-Policy - Keep report-uri active — violations in enforcement mode still indicate real issues or emerging threats
Bypass techniques (know what you're defending against)
CSP is not a silver bullet. Common bypasses:
- JSONP endpoints —
script-src *.google.comallows loading any JSONP endpoint on Google's domains - AngularJS —
'unsafe-eval'or certain CDN-hosted AngularJS versions enable template injection bypasses data:in script-src — allows<script src="data:text/javascript,alert(1)">in some browsers- Open redirects on allowed domains —
<script src="https://allowed.com/redirect?url=https://evil.com/xss.js">
The CSP Evaluator tool from Google identifies many of these weaknesses automatically.
How PatchVex detects CSP issues
The PatchVex Web Scanner checks every HTTP response for:
- Presence — is the
Content-Security-Policyheader set? unsafe-inlinein script-src — flags as high severityunsafe-evalin script-src — flags as medium severity- Missing
object-src— flags if fallbackdefault-srcdoesn't restrict it - Missing
frame-ancestors— notes absence (overlaps with X-Frame-Options check) - Missing
base-uri— flags if absent - Wildcard source values — flags
*or scheme-only sources in sensitive directives - Report-only without enforcement — notes if only report-only is set
Findings include the full policy string, the specific issue, and remediation steps.