Content Security Policy
Your Page Has a Bouncer Problem
Here is the uncomfortable truth about frontend security: by default, your page trusts everything. Any script can run. Any image can load. Any connection can fire. The browser does not care if you intended to load that script from sketchy-cdn.biz or if an attacker injected it through a form field.
Content Security Policy (CSP) fixes this. It is an HTTP header (or meta tag) that tells the browser: "Here is an explicit list of sources I trust. Block everything else."
Think of it like a nightclub bouncer with a guest list. No list, no entry. Even if someone sneaks past your server-side validation and injects a malicious script tag into your HTML, the browser refuses to execute it because the script's source is not on the list.
Without CSP, your page is an open house party — anyone can walk in. With CSP, it is a private event with a guest list. The browser checks every resource (script, style, image, font, connection) against the list before allowing it. Resources not on the list get blocked, and the browser tells you about it.
How CSP Works
CSP works through directives — each one controls a specific type of resource. You send these directives as an HTTP response header, and the browser enforces them for the entire lifetime of that page.
Content-Security-Policy: script-src 'self' https://cdn.example.com; style-src 'self'; img-src *
That header says:
- Scripts can only load from the same origin or
https://cdn.example.com - Styles can only load from the same origin
- Images can load from anywhere
Anything not matching these rules gets blocked. The browser logs a violation in the console, and if you have reporting configured, it sends the violation to your server.
The Core Directives
You do not need to memorize every directive, but you need to know the ones that matter in practice. Here are the directives you will actually use:
default-src — The Fallback
default-src is the catch-all. Any directive you do not explicitly set falls back to default-src. This is why most CSP policies start here:
Content-Security-Policy: default-src 'self'
This single directive restricts everything — scripts, styles, images, fonts, connections, frames — to the same origin. It is the most restrictive starting point, and you loosen it from there.
script-src — The Most Important One
Controls where JavaScript can load from. This is the directive that prevents XSS. Common values:
'self'— same origin only'nonce-abc123'— inline scripts with a matching nonce attribute'strict-dynamic'— trust scripts loaded by already-trusted scriptshttps://cdn.example.com— specific external origin'unsafe-inline'— allows all inline scripts (defeats the purpose of CSP)'unsafe-eval'— allowseval(),Function(),setTimeout('string')(dangerous)
style-src — Stylesheet Control
Controls where CSS can load from. Same source values as script-src. The tricky part: many CSS-in-JS libraries inject inline styles, which means you might need 'unsafe-inline' for styles (less dangerous than for scripts, but still not ideal).
img-src — Image Sources
Controls where images can load from. Commonly set to * or a specific CDN domain. Data URIs need explicit data: in the source list.
connect-src — Network Connections
Controls where fetch(), XMLHttpRequest, WebSocket, and EventSource can connect. If your frontend talks to an API on a different domain, that domain must be listed here.
frame-ancestors — Who Can Embed You
This one is different — it controls which pages can embed your page in an iframe. Setting frame-ancestors 'none' is the modern replacement for the X-Frame-Options: DENY header. It prevents clickjacking attacks.
Content-Security-Policy: frame-ancestors 'self' https://trusted-partner.com
Unlike other directives, frame-ancestors does not fall back to default-src and cannot be set via a meta tag. It only works as an HTTP header.
Other Useful Directives
| Directive | Controls | Common Use |
|---|---|---|
font-src | Font files | CDN or self |
media-src | Audio and video | Media CDN |
object-src | Plugins (Flash, Java) | Set to 'none' always |
base-uri | The base tag | Set to 'self' to prevent base tag injection |
form-action | Form submission targets | Prevents form hijacking |
upgrade-insecure-requests | Upgrades HTTP to HTTPS | Use on HTTPS sites to catch mixed content |
Nonce-Based CSP for Inline Scripts
Here is the problem: modern frameworks often need inline scripts. Server-side rendering might inject a hydration script. Analytics snippets go inline. If you block all inline scripts with script-src 'self', your own code breaks.
The old solution was 'unsafe-inline' — but that defeats the purpose of CSP entirely because attackers inject inline scripts too.
The modern solution: nonces. A nonce is a random, single-use token that your server generates on every request. You put it in both the CSP header and the script tag. The browser only executes inline scripts with a matching nonce.
Content-Security-Policy: script-src 'nonce-a1b2c3d4e5f6'
<!-- This runs — nonce matches -->
<script nonce="a1b2c3d4e5f6">
console.log('trusted inline script');
</script>
<!-- This is blocked — no nonce, or wrong nonce -->
<script>
console.log('injected by attacker');
</script>
The key requirements for nonces:
- Generate a new nonce on every request — reusing nonces is a security vulnerability. Use a cryptographically random value (at least 128 bits)
- Send the nonce in the header AND the script tag — both must match
- Never expose the nonce in a URL or cacheable response — CDN-cached pages with nonces are dangerous because every user gets the same nonce
// Node.js / Express example
import crypto from 'crypto';
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.nonce = nonce;
res.setHeader(
'Content-Security-Policy',
`script-src 'nonce-${nonce}'`
);
next();
});
Nonces vs hashes
CSP also supports hash-based allowlisting: script-src 'sha256-abc123...'. Instead of a nonce, you compute the SHA-256 hash of the inline script's contents and put it in the policy. The browser hashes the script and compares.
Hashes are useful for static inline scripts that never change (like a small polyfill). But they break the moment you change a single character in the script. For dynamic inline scripts (server-rendered hydration data, for example), nonces are far more practical.
One more thing: nonces work with strict-dynamic (covered next). Hashes do too, but nonces are the standard approach for modern CSP.
strict-dynamic — Trust the Trust Chain
Real-world apps load scripts dynamically. Your main bundle uses import() to lazy-load routes. A tag manager loads third-party scripts at runtime. With a basic script-src policy, you would need to list every possible script source — including ones you do not know at build time.
strict-dynamic solves this. When you add 'strict-dynamic' to script-src, the browser applies a simple rule: any script loaded by an already-trusted script is also trusted.
Content-Security-Policy: script-src 'nonce-abc123' 'strict-dynamic'
With this policy:
- Only scripts with
nonce-abc123are directly trusted - If a trusted script uses
document.createElement('script')to load another script, that dynamically loaded script is also trusted - Host-based allowlists (
https://cdn.example.com) are ignored whenstrict-dynamicis present 'unsafe-inline'is also ignored whenstrict-dynamicis present
This is powerful because it means your CSP policy does not need to know every CDN or third-party domain upfront. You just need to nonce your entry-point scripts, and their dynamic imports are automatically trusted.
When strict-dynamic is present, host-based source expressions like https://cdn.example.com and 'unsafe-inline' are ignored. This is intentional — Google designed it this way so you can include fallback values for older browsers that do not support strict-dynamic. Old browsers ignore the nonce and strict-dynamic but honor the host allowlist. New browsers honor the nonce and strict-dynamic and ignore the host allowlist. It is forward and backward compatible by design.
Reporting: Know When Violations Happen
CSP is not just a blocking mechanism — it is also a monitoring tool. You can configure the browser to report violations to an endpoint you control.
report-uri (Legacy)
Content-Security-Policy: default-src 'self'; report-uri /csp-report
The browser sends a JSON POST to /csp-report for every violation. The payload includes the violated directive, the blocked URI, and the page where it happened.
report-to (Modern)
The newer approach uses the Reporting API. You first define a reporting group, then reference it in your CSP:
Reporting-Endpoints: csp-endpoint="/csp-report"
Content-Security-Policy: default-src 'self'; report-to csp-endpoint
In practice, use both for compatibility:
Content-Security-Policy: default-src 'self'; report-uri /csp-report; report-to csp-endpoint
What Violation Reports Look Like
{
"csp-report": {
"document-uri": "https://example.com/page",
"violated-directive": "script-src 'self'",
"blocked-uri": "https://evil.com/steal.js",
"status-code": 200,
"original-policy": "script-src 'self'; report-uri /csp-report"
}
}
This tells you exactly what was blocked and why. In production, you will see violations from browser extensions, ad injectors, and sometimes your own code that you forgot to allowlist. Reporting helps you distinguish real attacks from false positives before you enforce a strict policy.
Deploying CSP: The Report-Only Strategy
Here is where most teams get CSP wrong: they write a strict policy, deploy it to production, and immediately break their site. Login forms stop working. Third-party widgets disappear. Analytics scripts get blocked.
The correct approach is incremental deployment using report-only mode.
Step 1: Deploy in Report-Only Mode
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; style-src 'self'; report-uri /csp-report
Content-Security-Policy-Report-Only is a separate header that monitors without enforcing. The browser logs violations and sends reports, but does not block anything. Your site works exactly as before, but you now see what would break under the policy.
Step 2: Analyze Reports
Run in report-only for at least a week. You will discover:
- Third-party scripts you forgot about (analytics, chat widgets, A/B testing)
- Inline scripts that need nonces
- Images loaded from unexpected CDNs
- Browser extensions triggering false-positive violations
Step 3: Adjust Your Policy
Add legitimate sources to the allowlist. Replace 'unsafe-inline' scripts with nonce-based loading. Remove sources you do not actually need.
Step 4: Switch to Enforcement
Once reports are clean (or only showing expected noise like browser extensions), change the header from Content-Security-Policy-Report-Only to Content-Security-Policy. Keep reporting active — new violations may appear as your app evolves.
CSP via Meta Tags vs HTTP Headers
You can set CSP in two ways:
HTTP header (recommended):
Content-Security-Policy: default-src 'self'; script-src 'nonce-abc123'
Meta tag (limited):
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'nonce-abc123'">
The meta tag approach has significant limitations:
frame-ancestorsdoes not work in meta tagsreport-uriandreport-todo not work in meta tagssandboxdoes not work in meta tags- The meta tag must be in
<head>— if an attacker can inject content before your meta tag, CSP is useless - Multiple meta tags create independent policies (both must pass), which is confusing
Use HTTP headers whenever possible. Meta tags are a fallback for static hosting where you cannot control headers (like GitHub Pages), but they are strictly worse.
Common Mistakes That Break Your CSP
The two most dangerous CSP values are 'unsafe-inline' and 'unsafe-eval'. They exist for backward compatibility, but using them guts the security benefits of CSP.
unsafe-inline — The XSS Backdoor
Content-Security-Policy: script-src 'self' 'unsafe-inline'
This policy says "allow scripts from my origin... and also allow any inline script." An attacker who injects <script>alert('xss')</script> into your page wins. The entire point of CSP is to prevent this, and 'unsafe-inline' disables that protection.
The fix: use nonces instead. Every legitimate inline script gets a nonce. Injected scripts do not have the nonce and get blocked.
unsafe-eval — The Code Injection Backdoor
Content-Security-Policy: script-src 'self' 'unsafe-eval'
This allows eval(), new Function(), and setTimeout with string arguments. These are the exact functions attackers use to execute injected code. Some older libraries require eval (like certain template engines), but modern alternatives exist for nearly everything.
Overly Broad Allowlists
Content-Security-Policy: script-src 'self' https: data:
https: allows scripts from any HTTPS domain. data: allows data URI scripts. Both are effectively the same as having no CSP. An attacker just needs to host their payload on any HTTPS server.
Forgetting object-src
If you set script-src but not object-src, attackers can use <object> or <embed> tags to load malicious Flash or other plugin content. Always set object-src 'none' — there is no legitimate reason for plugins in modern web apps.
| What developers do | What they should do |
|---|---|
| Using unsafe-inline in script-src unsafe-inline allows any injected script to execute, completely defeating CSP protection against XSS | Use nonce-based CSP for inline scripts |
| Using unsafe-eval in script-src unsafe-eval lets attackers execute arbitrary code through eval-like functions — the exact attack vector CSP should prevent | Refactor code to avoid eval, new Function, and string-based setTimeout |
| Setting script-src to https: or * Broad allowlists let attackers host payloads on any HTTPS domain and bypass your CSP entirely | Allowlist specific trusted domains only |
| Deploying strict CSP without report-only first Jumping straight to enforcement will break third-party scripts, inline code, and widgets you forgot about | Run Content-Security-Policy-Report-Only for 1-2 weeks before enforcing |
| Setting CSP via meta tag and relying on frame-ancestors Several important directives are silently ignored in meta tags, giving you a false sense of security | Use HTTP headers for CSP — meta tags ignore frame-ancestors, report-uri, and sandbox |
Putting It All Together: A Production CSP
Here is a realistic CSP for a modern web app with a CDN, analytics, and server-rendered inline scripts:
Content-Security-Policy:
default-src 'self';
script-src 'nonce-{SERVER_GENERATED}' 'strict-dynamic';
style-src 'self' 'unsafe-inline';
img-src 'self' https://cdn.example.com data:;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
object-src 'none';
base-uri 'self';
form-action 'self';
report-uri /csp-report;
report-to csp-endpoint
Breaking this down:
- Scripts: nonce-based with
strict-dynamic— no host allowlist needed for scripts - Styles:
'unsafe-inline'is used because CSS-in-JS libraries often require it (less dangerous for styles than scripts since CSS cannot execute arbitrary code) - Images: same origin, specific CDN, and
data:for inline images - Fonts: same origin plus Google Fonts
- Connections: same origin plus your API domain
- Frames: nobody can embed your page
- Objects: no plugins allowed
- Base/Form: locked to same origin to prevent injection attacks
- Reporting: both legacy and modern reporting configured
CSP in Next.js
If you are using Next.js, you configure CSP through middleware. Here is the pattern:
// 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 cspHeader = `
default-src 'self';
script-src 'nonce-${nonce}' 'strict-dynamic';
style-src 'self' 'unsafe-inline';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
`.replace(/\n/g, '');
const response = NextResponse.next();
response.headers.set('Content-Security-Policy', cspHeader);
// Pass nonce to the page via request headers
response.headers.set('x-nonce', nonce);
return response;
}
The nonce is generated per-request in middleware and passed to your components so they can add it to inline script tags. This works well with Server Components because middleware runs on the server for every request.
- 1Start with default-src 'self' and loosen from there — never the other way around.
- 2Never use unsafe-inline for scripts. Use nonces instead.
- 3Always set object-src 'none' — there is zero reason for plugins in modern web apps.
- 4Deploy in Content-Security-Policy-Report-Only mode first. Enforce only after reports are clean.
- 5Use strict-dynamic with nonces so dynamically loaded scripts are trusted without listing every CDN.
- 6Use HTTP headers for CSP, not meta tags. Meta tags silently ignore frame-ancestors, report-uri, and sandbox.
- 7Generate a new cryptographically random nonce on every request. Never reuse nonces.
- 8Keep reporting active even after enforcement — your app evolves, and new violations will appear.