DEV Community

Cover image for Content Security Policy: A Complete Implementation Guide for Modern Web Apps
Veduis
Veduis

Posted on Originally published at veduis.com on

Content Security Policy: A Complete Implementation Guide for Modern Web Apps

Content Security Policy (CSP) is the most powerful XSS defense available to web developers. When properly implemented, it prevents injected scripts from executing even if an attacker successfully injects HTML, because the browser only executes scripts from sources your policy explicitly permits.

The reason CSP has a reputation for being hard to implement isn't the directive syntax; that part is straightforward. It's that most web applications have legitimate inline scripts and styles, third-party embeds, and dynamic content that requires careful policy design to accommodate without weakening the security guarantees. Getting from no CSP to a strict policy that doesn't break your application requires systematic work.

This guide walks through that process end to end. It pairs well with our web accessibility and WCAG conformance guide, which covers the compliance side of front-end work, our front-end web security and attack mitigation guide, and our operational breakdown on Layer 7 web application firewall fundamentals. If you distribute logic across edge networks, our edge CDN architecture and security analysis shows where response headers fit in that model. For the formal directive definitions, the MDN CSP reference and the W3C CSP Level 3 specification are the authoritative sources.

How CSP Works

CSP is delivered as an HTTP response header. The browser reads the policy and enforces it for every resource loaded by the page: scripts, styles, images, fonts, frames, API calls. Any resource load that violates the policy is blocked and optionally reported. A basic policy looks like Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; img-src *.

The browser treats the policy as a whitelist: if a resource's source isn't explicitly permitted, it's blocked. The default-src directive serves as a fallback for any resource type not explicitly listed.

Technical schematic of an HTTP header enforcement filter on dark titanium with cryptographic verification gates

Core Directives

script-src

Controls where JavaScript can execute. The most important directive. Common values: script-src 'self' allows only same-origin scripts, script-src 'self' https://cdn.jsdelivr.net adds a specific CDN, and script-src 'none' disables JavaScript entirely.

style-src

Controls CSS sources. Inline styles require 'unsafe-inline' or hashes/nonces.

img-src

Controls image sources. img-src * (wildcard) is acceptable for images since external images cannot execute code.

connect-src

Controls which URLs can be reached via fetch(), XMLHttpRequest, WebSocket, and EventSource.

frame-src / child-src

Controls what can be loaded in iframes. Default to 'none' unless you specifically need iframe embeds.

base-uri

Controls what URLs can be used in <base> elements. Always set to 'self' or 'none': base injection attacks can redirect all relative URLs.

form-action

Controls where forms can submit. Often forgotten but critical.

Starting Point: Report-Only Mode

Never deploy an enforcement-mode CSP on a production site without first running in report-only mode. Report-only mode sends violation reports without blocking anything; you see what would break without breaking it. You enable it with the Content-Security-Policy-Report-Only header and point report-uri at a collection endpoint, for example report-uri /api/csp-report.

On the server, that endpoint accepts POST requests with a application/csp-report content type, extracts fields like blocked-uri, violated-directive, source-file, and line-number from the csp-report object in the body, and stores them for analysis. Group violations by violated-directive and blocked-uri to understand what your policy needs to accommodate, and collect reports for at least a week across realistic user traffic before enforcing.

The Nonce-Based Approach (Recommended)

The most common CSP mistake is using 'unsafe-inline' to allow inline scripts. This defeats the primary purpose of CSP; an attacker who injects <script>maliciousCode()</script> into your page will have it executed.

The correct solution is nonces (number-used-once). Each page response generates a fresh random nonce, includes it in the CSP header, and adds it as an attribute to every legitimate inline script:

app.use((req, res, next) => {
  const nonce = crypto.randomBytes(16).toString('base64');
  res.locals.cspNonce = nonce;
  res.setHeader('Content-Security-Policy',
    `default-src 'self'; script-src 'self' 'nonce-${nonce}'; base-uri 'self'`);
  next();
});
Enter fullscreen mode Exit fullscreen mode

In your templates, every legitimate inline script carries the nonce attribute, like <script nonce="<%= nonce %>">const config = { ... }</script>. An injected script won't have the matching nonce, so the browser blocks it.

The nonce must be cryptographically random (not predictable), different on every page response (not static), and long enough to be unguessable (128 bits / 16 bytes minimum).

Next.js CSP with nonces

In Next.js, generate the nonce in middleware.ts using crypto.randomUUID() converted to base64, build the policy string with script-src 'self' 'nonce-${nonce}' 'strict-dynamic', and pass the nonce to the response via a custom request header such as x-nonce. In app/layout.tsx, read it back with headers().get('x-nonce') and pass it to the Script component, which applies the nonce attribute automatically. Collapse the multiline policy string with a whitespace replacement before setting the Content-Security-Policy header on the response.

Hash-Based Inline Script Approval

For truly static inline scripts (not generated per request), use hashes instead of nonces. Calculate a SHA-256 hash of the exact script content: echo -n "console.log('hello world');" | openssl dgst -sha256 -binary | openssl base64, then allow it in the policy as script-src 'self' 'sha256-RFWPLDbv2BY+rCkDzsE+0fr8ylGr2R2faWMhq4lfEQc='. The script whose content matches that hash byte-for-byte will be allowed to run.

Hashes work well for configuration scripts that don't change per request. Any change to the script content, even whitespace, invalidates the hash.

Dual comparison render of inline script defense mechanisms on dark matte graphite contrasting nonces against SHA-256 hashes

Handling Third-Party Scripts

Third-party scripts are the biggest CSP challenge. Analytics, chat widgets, A/B testing tools, customer success platforms: all require script execution from external origins, and many inject their own inline scripts.

Approach 1: Allow the CDN domain

Add specific origins to script-src, like script-src 'self' https://www.googletagmanager.com https://widget.intercom.io. This allows scripts from those origins but not arbitrary injection. The risk: if those CDN assets are compromised, your users are affected.

Approach 2: Use strict-dynamic with nonces

'strict-dynamic' is the right solution for trusted scripts that load other scripts. A policy of script-src 'nonce-{random}' 'strict-dynamic' works like this: only scripts with the correct nonce (those you explicitly included in your HTML) are initially trusted, those trusted scripts can dynamically create and append script elements which are also trusted, and domain allowlists are ignored in favor of nonce-verified entry points. This means Google Tag Manager (loaded with a nonce) can inject its own analytics scripts without needing their domains in your policy.

Approach 3: Proxy-loaded scripts

For maximum control, proxy third-party scripts through your own origin. Serve the third-party file from a route on your domain, fetch and cache it server-side, compute its SHA-256 hash on each fetch, compare it against a known-good hash, and refuse to serve it (with an alert to your logging system) if the hash mismatches. Now the URL lives under your own domain and is covered by your 'self' allowance, and you get integrity checking on top.

3D isometric technical pipeline of a third-party script trust chain with strict-dynamic and isolated sandboxes

Dealing with Common CSP Challenges

Inline event handlers

Inline event handlers like <button onclick="doSomething()"> are blocked by CSP. Move the handler into an external or nonce-approved script and use addEventListener, for example document.getElementById('my-button').addEventListener('click', doSomething).

Inline styles

Inline style attributes require 'unsafe-inline' in style-src, which weakens your policy. For static styles, use CSS classes. For dynamic values, use CSS custom properties: set style="--user-color: red" on the element and reference var(--user-color) in a stylesheet class. Alternatively, allow inline <style> blocks that carry the nonce attribute.

eval() usage

eval(), Function(), and setTimeout('string') require 'unsafe-eval'. This is a significant security weakening, these features enable arbitrary code execution from strings. Audit your codebase with a search for eval, new Function, and string-argument setTimeout calls across your JS and TS files. Most eval usage in third-party libraries can be replaced with alternatives or eliminated by switching to a different library. If you genuinely need it, scope 'unsafe-eval' as narrowly as possible.

A Production-Ready CSP Template

Here's a starting policy that's strict enough to be meaningful while accommodating common requirements:

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{NONCE}' 'strict-dynamic' https:; style-src 'self' 'nonce-{NONCE}' https://fonts.googleapis.com; img-src 'self' data: https: blob:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://api.yourdomain.com wss://realtime.yourdomain.com; media-src 'self' https://cdn.yourdomain.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests; report-uri /api/csp-report

The https: fallback in script-src (after strict-dynamic) provides backward compatibility for browsers that don't support strict-dynamic. Browsers that do support it ignore the fallback. Before shipping any policy, run it through the Google CSP Evaluator to catch bypassable configurations.

Using Helmet in Express

Helmet simplifies CSP header management in Express. Call helmet.contentSecurityPolicy({ useDefaults: false, directives: { ... } }) with camelCase directive names: defaultSrc: ["'self'"], scriptSrc including a function that returns 'nonce-${res.locals.cspNonce}' for dynamic nonces, plus 'strict-dynamic' and https:, imgSrc: ["'self'", "data:", "https:"], objectSrc: ["'none'"], baseUri, formAction, and frameAncestors all set to 'self' or 'none', and reportUri pointing at your report endpoint. Note the trade-off in most real apps: styleSrc often needs 'unsafe-inline' because UI libraries inject inline styles, and forcing nonces on styles breaks more than it protects.

Debugging CSP Violations

When violations occur in production, the report tells you what you need: document-uri shows the affected page, blocked-uri shows what was stopped, violated-directive names the policy line that fired, and source-file plus line-number locate the reference in your code. A blocked-uri of inline means an inline script without a matching nonce or hash, eval means an eval() call, and blob: means a blob URL. Browser developer tools show CSP violations in the Console panel with the blocked resource URL, the violated directive, and the line number of the blocked resource reference. Use the Content-Security-Policy-Report-Only header alongside your enforcement header to test policy changes without affecting users.

CSP is a defense-in-depth measure; it's not a substitute for input validation and output encoding, but it's the catch-all that stops injected content from doing damage even when those primary defenses miss something.

Glassmorphic web application security telemetry HUD displaying real-time CSP violation stream filters and falling alert triage curves

What Most Guides Get Wrong

Most CSP tutorials tell you to paste a strict policy into your server config and fix what breaks. That advice inverts the actual risk. The sites that suffer from XSS are rarely the ones with a slightly permissive policy; they are the ones that never deployed CSP at all because the strict version broke checkout on day one. A policy you actually ship with script-src 'self' https: beats a perfect nonce-based policy that lives in a ticket backlog for two years. Guides also treat 'unsafe-inline' in style-src as equivalent to 'unsafe-inline' in script-src. It is not. Style injection is a much smaller attack surface, and most component frameworks require inline styles to function, so fighting that battle costs you weeks for marginal risk reduction. Another common mistake: recommending hash-based policies for everything. Hashes silently break the moment a marketing tool edits one inline pixel, and a broken allowlist often gets "fixed" by someone adding 'unsafe-inline' back. Nonces fail loudly; hashes fail quietly. Finally, almost no guide tells you to budget for report triage. Violation reports from real traffic are noisy, full of browser-extension noise, and someone has to own reviewing them weekly or your report-only phase never ends.

Conclusion

Start every CSP rollout in report-only mode with a real report collection endpoint, and give the report-only phase a calendar deadline so it doesn't drift. Use nonces for inline scripts, reserve hashes for genuinely static blocks, and lean on 'strict-dynamic' instead of growing your domain allowlist every time a new vendor appears. Add third-party origins only after proxying or nonce-chaining fails, and validate the final policy with an automated evaluator before enforcement. Input validation and output encoding remain your first line of defense, CSP is the backstop that makes a missed vulnerability an annoyance instead of an incident. Ship the imperfect policy this month rather than the perfect one next quarter.

Top comments (0)