Most Content Security Policies in production either break the site or protect very little, because they were written as long domain allow lists, left in report-only mode forever, or quietly weakened with 'unsafe-inline' to make an error go away. A well-built CSP is one of the few controls that can stop an injected script from running even after an XSS bug ships, and it is the browser-side control behind payment page script management. This guide covers how to write a strict policy, which directives matter, how to collect and triage violation reports, how to roll out without outages, and how to keep the policy strict as the application changes. It is for web developers, front-end leads and AppSec engineers. The Security Headers Guide places CSP among the other response headers; this page goes deep on CSP alone.
Key takeaways
- Use a strict policy, not an allow list. Nonce- or hash-based
script-srcwith'strict-dynamic', plusobject-src 'none'andbase-uri 'none', is the pattern recommended by OWASP and Google because domain allow lists are routinely bypassable. - CSP is a second line of defence. It limits what an injected script can do; it does not remove the injection. Output encoding and safe templating come first.
- Roll out in report-only mode, then enforce.
Content-Security-Policy-Report-Onlylets you run the strict policy against real traffic and fix breakage before anything is blocked. - Reports are a detection feed, not noise. Collect them through
Reporting-Endpointsandreport-to, filter extension noise, and alert on new script sources on sensitive pages. - Add frame-ancestors and Trusted Types once the basics hold.
frame-ancestorsreplaces X-Frame-Options, andrequire-trusted-types-for 'script'closes DOM XSS sinks that CSP script rules alone cannot.
What CSP does and what it does not do
Content Security Policy is a response header (or, with limits, a <meta> element) that tells the browser which sources of script, style, frames, connections and other resources the page may use, and which navigation and embedding are allowed. The current specification is CSP Level 3, a W3C Working Draft. When an injected script does not carry a valid nonce or hash, a strict policy stops it executing, which turns many reflected, stored and DOM XSS bugs from exploitable into blocked and reported.
CSP does not fix the underlying bug, does not stop HTML injection that steals data without script (dangling markup and form hijacking are only partly covered by form-action and other directives), does not protect against a compromised script you deliberately trust, and does not apply to non-browser API clients. The OWASP cheat sheet is explicit that CSP is not a replacement for secure coding. See the Cross-Site Scripting (XSS) Prevention Guide and the Input Validation and Output Encoding Guide.
| Threat | What CSP contributes | Directive | Still needed |
|---|---|---|---|
| Reflected and stored XSS (MITRE ATT&CK T1059.007 JavaScript) | Blocks injected inline and external scripts without a nonce or hash | script-src with nonce or hash and 'strict-dynamic' | Contextual output encoding |
DOM XSS through innerHTML and similar sinks | Trusted Types force sink assignments through reviewed policies | require-trusted-types-for, trusted-types | Safe DOM APIs, sanitiser |
| Base tag injection redirecting relative script URLs | Prevents an injected <base> changing where scripts load from | base-uri 'none' or 'self' | Output encoding |
| Plugin content | Blocks <object> and <embed> | object-src 'none' | None in modern browsers |
| Clickjacking | Controls which origins may frame the page | frame-ancestors | Header delivery (not meta) |
| Form hijacking to attacker hosts | Limits where forms may submit | form-action | Encoding |
| Data exfiltration and skimming by injected or tampered script (T1185 Browser Session Hijacking) | Limits where script can send data | connect-src, img-src, default-src | Script inventory and integrity monitoring |
| Mixed content during HTTPS migration | Upgrades HTTP subresource requests | upgrade-insecure-requests | HSTS |
Strict CSP versus allow-list CSP
An allow-list policy such as script-src 'self' cdn.example.com www.googleapis.com trusts every script on every listed host. Large shared hosts serve JSONP endpoints, old library versions and user content that an attacker can use to run code within the policy, so allow lists are hard to keep both working and safe. Google's web.dev guidance and the OWASP cheat sheet both recommend strict policies instead.
| Approach | Policy core | Best for | Trade-offs |
|---|---|---|---|
| Nonce-based strict | script-src 'nonce-{random}' 'strict-dynamic'; object-src 'none'; base-uri 'none' | Server-rendered pages where each response can carry a fresh nonce | Needs templating changes; responses with nonces cannot be cached and shared between users as-is |
| Hash-based strict | script-src 'sha256-{hash}' 'strict-dynamic'; object-src 'none'; base-uri 'none' | Static sites, CDN-cached pages and single-page app shells | Any change to an inline script, even whitespace, changes the hash; needs a build step |
| Allow list | script-src 'self' host1 host2 | Legacy sites as an interim step, or as a backstop for directives other than script | Bypassable through hosted gadgets; grows without bound |
Allow list with 'unsafe-inline' | script-src 'self' 'unsafe-inline' | Nothing | Offers almost no XSS protection; remove it |
'strict-dynamic' lets a script that is already trusted (by nonce or hash) load further scripts it creates, so tag managers and loaders work without listing every host. In browsers that support it, host allow lists and 'unsafe-inline' in the same directive are ignored. That is why web.dev suggests script-src 'nonce-{random}' 'strict-dynamic' https: 'unsafe-inline' when you must support old browsers: modern ones ignore the fallbacks, older ones fall back to a weaker policy rather than breaking.
Writing the policy: directives that matter
A practical starting policy
For a server-rendered application, a reasonable first strict policy delivered as a header is:
Content-Security-Policy: script-src 'nonce-R4nd0m' 'strict-dynamic' https: 'unsafe-inline'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; form-action 'self'; report-to csp-endpoint
with Reporting-Endpoints: csp-endpoint="https://example.com/csp-reports". Add default-src, connect-src, img-src and style-src restrictions later, once script control is enforced, because each one adds breakage risk for less XSS protection.
Nonce rules
- Fresh per response: generate a new nonce for every HTTP response with a cryptographically secure generator (ASVS 5.0 V11.5.1 asks for at least 128 bits of entropy for non-guessable values). A nonce reused across responses or cached in shared HTML is a key an attacker can read and reuse.
- Applied by the template, not by a filter: the OWASP cheat sheet warns against middleware that adds the nonce to every script tag in the output, because injected scripts would then receive it too.
- Exact match: the browser compares the nonce as a string; it does not decode it.
Hash rules
- Generate in the build: compute
sha256,sha384orsha512hashes of each inline script during the build and inject them into the header or meta tag. - External scripts: under CSP Level 3 a hash can also allow an external script whose
<script>element carries matching Subresource Integrity metadata.
Refactoring code that strict CSP blocks
- Inline event handlers:
onclick="..."attributes are blocked; move them toaddEventListenercalls in nonce'd or external scripts.'unsafe-hashes'exists for handlers you cannot move, but treat it as a temporary exception. javascript:URLs: replace with event listeners.eval()andnew Function(): replace withJSON.parse()or precompiled templates. Avoid adding'unsafe-eval'; if WebAssembly needs compilation, use the narrower'wasm-unsafe-eval'.- Inline styles:
style-srcrestrictions break many component libraries. Treat style control as a later phase; script control delivers most of the value.
Header versus meta delivery
A policy in a <meta http-equiv> element supports most script controls, but per CSP Level 3 it cannot carry frame-ancestors, sandbox or report-uri, cannot be report-only, and does not apply to content before it in the document. Use the header for anything beyond a static site, and place a meta policy as early as possible in <head> if you must use one.
Reporting and violation triage
Reporting is what turns CSP from a configuration into a monitoring control. ASVS 5.0 V3.4.7 requires the policy to specify a location to report violations.
- Endpoints: declare an HTTPS endpoint with
Reporting-Endpoints: csp-endpoint="https://..."and reference it withreport-to csp-endpoint.report-uriis deprecated in CSP Level 3 but older browsers only understand it, so the OWASP cheat sheet suggests sending both during transition.Reporting-Endpointsreplaces the olderReport-Toheader. - Samples: add
'report-sample'toscript-srcto receive the first 40 characters of a blocked inline script, which is usually enough to tell a browser extension from a real injection. - Fields to keep: document URL, blocked URL, effective directive, disposition (enforce or report), source file and line where present, and user agent. Store them like any other security log.
- Filter noise: browser extensions, injected antivirus scripts and translation tools generate large volumes of reports with
chrome-extension:or similar sources. Filter by scheme and known patterns, but do not discard reports from your own sensitive pages without review. - Protect the endpoint: it accepts unauthenticated POSTs from the internet, so rate-limit it, cap body size and treat report content as untrusted input in any dashboard. See the Rate Limiting and API Abuse Guide.
| Violation pattern | Likely cause | Action |
|---|---|---|
| Inline script blocked on many pages, same sample | Legitimate inline script without nonce or hash | Add nonce in the template or move to an external file |
Blocked chrome-extension: or moz-extension: source | User's browser extension | Filter out of alerting |
Blocked eval | Library using eval or new Function | Upgrade or replace the library, avoid 'unsafe-eval' |
| New external host on checkout or login page | New tag, compromised third party or injected skimmer | Investigate the same day |
| Inline script blocked with an unfamiliar sample on one URL | Possible XSS attempt | Correlate with WAF and application logs, test the parameter |
Rolling out CSP without breaking production
- Inventory scripts (week 1): list every first-party and third-party script per page template, who owns it and why it is there. Payment, login and account pages first.
- Refactor (weeks 2 to 6): add nonces in templates or hashes in the build, remove inline handlers and
javascript:URLs, replaceeval. - Report-only on real traffic (2 to 4 weeks): send the strict policy as
Content-Security-Policy-Report-Only. You can send it alongside an existing enforced policy, so a weak current policy stays in place while you test the strict one. - Validate: check the policy with Google's CSP Evaluator or Lighthouse, and add an automated test that fails the build if the header is missing or contains
'unsafe-inline'without a nonce or hash. - Enforce in rings: switch to
Content-Security-Policyon one low-risk template or a percentage of traffic, watch report volume and error budgets for a week, then widen. - Roll back cleanly: keep the header in configuration (gateway, CDN or framework setting) so you can switch a template back to report-only in minutes, rather than editing code under pressure. Rolling back to report-only keeps visibility; removing the header loses it.
- Tighten in phases: after script control is stable, add
frame-ancestors,form-action, thenconnect-srcandimg-srcon sensitive pages, then Trusted Types.
Small sites with few third-party scripts can often go from report-only to enforced in one release with a hash-based policy generated at build time. Large estates with many teams and tag managers need a central policy service or shared middleware, a per-application owner, and policy changes reviewed like code. Single-page applications usually suit a hash-based policy on the shell plus 'strict-dynamic'; check framework guidance, for example the React and Next.js Security Guide, Angular Security Guide and Vue Security Guide.
Beyond scripts: frame-ancestors, Trusted Types and third-party code
frame-ancestors
ASVS 5.0 V3.4.6 requires frame-ancestors on every response so the application cannot be embedded by default. Use 'none' for pages that should never be framed and 'self' or named origins where embedding is a feature. It must be delivered in a header. Keep X-Frame-Options only for very old browsers. See the Clickjacking Guide.
Trusted Types
DOM XSS happens in client code that assigns strings to sinks such as innerHTML, eval or script.src. With require-trusted-types-for 'script', those assignments throw a TypeError unless the value comes from a Trusted Types policy; trusted-types lists the policy names the code may create. A policy named default can catch legacy string assignments during migration. MDN lists Trusted Types as Baseline newly available since February 2026, so older browsers will not enforce it; test with enforcement on a supporting browser and roll out in report-only mode first like the rest of CSP.
Third-party scripts and payment pages
A strict CSP with 'strict-dynamic' trusts whatever your trusted scripts load, so a compromised tag manager or analytics script is still a supply chain path into the page. Pair CSP with a script inventory, Subresource Integrity on fixed-version scripts, restrictive connect-src on payment pages, and change monitoring. PCI DSS v4.0.1 requirement 6.4.3 requires payment page scripts to be authorised, integrity-checked and inventoried, and 11.6.1 requires a mechanism to detect unauthorised changes to security-impacting HTTP headers and payment page content; the PCI SSC published an information supplement, "Payment Page Security and Preventing E-Skimming", on these two requirements in 2025. CSP and its reports are one common way to meet part of them, not the whole answer. See the Software Supply Chain Security Guide.
Detection and measurement
- Monitor: violation volume per template and directive, new blocked hosts on sensitive pages, enforced versus report-only coverage, and responses served without the header.
- Correlate: pair violations with WAF events and application logs for the same URL and time. See the Web Application Firewall Guide.
Metrics that show CSP is working:
- Percentage of HTML responses with an enforced strict CSP (nonce or hash,
object-src 'none',base-uriset). - Number of applications with
'unsafe-inline'or'unsafe-eval'effective inscript-src(target zero). - Time from a new third-party script appearing on a payment or login page to review.
- Percentage of XSS findings from testing that the enforced policy would have blocked.
- Time spent in report-only before enforcement, per application (a policy stuck in report-only protects nothing).
Common failure patterns
- 'unsafe-inline' left in to fix one error: without a nonce or hash in the same directive, it disables most XSS protection.
- Permanent report-only: the policy was tested, never enforced, and nobody reads the reports.
- Static or cached nonces: the same nonce in every response, or HTML with a nonce cached at the CDN and served to everyone.
- Auto-nonce middleware: a filter that stamps the nonce on every script tag, including injected ones.
- Allow lists of large shared hosts: broad CDN or API domains that host content an attacker can reuse to run script within policy.
- Missing base-uri and object-src: a strict
script-srcundermined by base tag injection or plugin content. - Multiple conflicting headers: a gateway and the application each set a policy; browsers enforce both, so pages break unpredictably and teams respond by loosening one.
Framework mapping
| Framework | Reference | How CSP supports it |
|---|---|---|
| OWASP ASVS 5.0 | V3.1.1, V3.4.3, V3.4.6, V3.4.7 | Documented browser security features, CSP present, frame-ancestors on every response, violation reporting |
| NIST CSF 2.0 | PR.PS-06 | Secure software development practices |
| NIST SP 800-53 Rev. 5 | SA-8, SI-10, SI-7 | Security engineering principles, input handling, integrity of software and information |
| CIS Controls v8.1 | Control 16 Application Software Security | Secure application development and hardening |
| ISO/IEC 27001:2022 Annex A | A.8.26, A.8.28 | Application security requirements, secure coding |
| PCI DSS v4.0.1 | 6.4.3, 11.6.1 | Payment page script management and change detection |
| MITRE ATT&CK | T1059.007, T1185, T1189 | Limits injected JavaScript execution, browser session hijacking and drive-by script delivery |
Practitioner checklist
- Every HTML response carries a CSP header with a nonce- or hash-based
script-srcand'strict-dynamic'. object-src 'none'andbase-uri 'none'(or'self') are present everywhere.- Nonces are generated per response from a secure random source and applied by the template engine.
- No effective
'unsafe-inline'or'unsafe-eval'inscript-src; exceptions are time-limited and owned. frame-ancestorsis set in the header on every response.- Violation reports flow through
Reporting-Endpointsto a rate-limited endpoint and into the security log platform. - New script sources on payment and login pages raise an alert reviewed within one working day.
- CI fails if the header is missing or weakened; the policy is reviewed in CSP Evaluator after every change.
- Trusted Types are piloted in report-only mode on the main single-page application.
Standards and references
- W3C: Content Security Policy Level 3
- OWASP: Application Security Verification Standard 5.0
- OWASP: Content Security Policy Cheat Sheet
- Google web.dev: Mitigate cross-site scripting with a strict Content Security Policy
- MDN: Trusted Types API
- MDN: Reporting-Endpoints header
- PCI Security Standards Council: Payment Page Security and Preventing E-Skimming
Related NHI Mgmt Group resources: Security Headers Guide · Cross-Site Scripting (XSS) Prevention Guide · Clickjacking Guide · Secure API Design Guide