Content Security Policy (CSP): how it works, how to test it, how to roll it out

A Content Security Policy (CSP) is an HTTP response header that tells the browser which sources it may load scripts, styles, images, frames and connections from. It limits the damage of cross-site scripting but does not fix the flaw. A strict policy uses a per-response nonce, object-src 'none' and base-uri, and is rolled out first with the Report-Only header.

A Content Security Policy (CSP) is an HTTP response header that tells the browser where a page may load scripts, styles, images, frames and network connections from, and what inline code it may run. If an attacker injects a script tag into your page, a good policy makes the browser refuse to run it. A CSP reduces the impact of cross-site scripting (XSS). It does not remove the flaw, so it belongs next to output encoding, not in place of it.

A minimal header looks like this:

Content-Security-Policy: default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'

This page covers the directives that matter, a strict nonce-based policy, a Report-Only rollout, config for eight servers and frameworks, the bypass checks a tester runs, and the mistakes we see most. The header is the same thing that searchers call the "CSP header" and "content-security-policy-header".

How does a Content Security Policy work?#

The browser reads the policy from the response header and applies it to that one document. Each directive names a resource type and lists the sources allowed for it. When a page tries to load something the policy forbids, the browser blocks it and, if you set a reporting directive, sends a violation report.

Illustrative policy: Harbor Notes

This is a demonstration. No real customer system was tested. Harbor Notes is a fictional note app and example.com is a reserved example domain.

The app at https://app.example.com sends this header:

Content-Security-Policy: script-src 'self'; object-src 'none'; base-uri 'none'

A note title is printed into the page without encoding, so a tester saves the minimal, well-known probe <img src=x onerror=alert(1)> as a note title. The browser parses the page, finds an inline event handler, and compares it with script-src 'self'. Inline code is not allowed by 'self', so the browser blocks it and logs a console error that names the violated directive. The encoding flaw is still there. The policy stopped this one payload.

Two facts follow from this. First, the policy only helps in browsers that enforce it, and only on responses that carry the header. A page, an error response or an API that forgets the header has no protection. Second, a policy that allows 'unsafe-inline' in script-src allows the injected script as well, so the policy no longer blocks the main XSS pattern. OWASP's cheat sheet says to move inline code to files, replace inline handlers such as onclick with addEventListener, and use nonces or hashes when inline code cannot be avoided.

Which CSP directives matter most?#

The directives below control the resource types that matter most for security. The "if missing" column says what the browser does when the directive is absent, based on MDN and the W3C CSP Level 3 text.

Directive Controls If missing or too loose
default-src Fallback for most fetch directives Directives without their own rule have no limit
script-src JavaScript sources, inline scripts, eval Injected scripts can run. 'unsafe-inline' removes most of the value
style-src Stylesheets and inline styles Injected styles can restyle the page or leak data through CSS
img-src Image sources Images can send data to other hosts in the URL
connect-src fetch, XHR, WebSocket, EventSource targets Script that runs can send data to any host
frame-src Origins the page may embed Page can embed any origin
frame-ancestors Which origins may embed this page Page can be framed by any site (see clickjacking)
form-action Where forms may submit An injected form can post to another host
base-uri Allowed values for the <base> tag An injected <base> tag can redirect relative script paths
object-src Plugins such as <object> and <embed> Plugin content can load. Set 'none'
report-to, report-uri Where violation reports go You get no data on violations

default-src does not cover frame-ancestors, form-action or base-uri. You must set each of these directly. That surprises many teams that write only default-src 'self'.

What is a strict CSP with nonces?#

A strict CSP trusts scripts by a per-response secret, not by their host name. The OWASP cheat sheet gives this nonce-based form:

Content-Security-Policy: script-src 'nonce-{RANDOM}' 'strict-dynamic'; object-src 'none'; base-uri 'none';

Create a new random nonce for every response and put the same value in the header and in the nonce attribute of each script tag. A script that has the nonce can load more scripts, and 'strict-dynamic' makes the browser trust those too. An injected script has no nonce and is blocked. The hash form replaces the nonce with 'sha256-...' of the inline script, but any change to the script, even whitespace, breaks the match.

OWASP warns against middleware that adds a nonce to every script tag in the output. If it tags an injected script too, the nonce protects nothing. Add nonces in the template engine, to the tags you wrote.

Caching matters here. A cached page that contains a nonce reuses the same nonce for every viewer, and Django's CSP documentation says this "defeats the purpose of the nonce". Do not put nonce pages behind a full-page cache or CDN cache.

What real research and incidents show about CSP?#

The best public data on CSP quality is a Google study. In 2016, Lukas Weichselbaum, Michele Spagnuolo, Sebastian Lekies and Artur Janc published "CSP Is Dead, Long Live CSP!" at ACM CCS in Vienna. They analyzed policies from about 100 billion pages and found 26,011 unique policies. They reported bypasses in 94.72% of the distinct policies. They also found that 14 of the 15 domains most often allowed for scripts hosted unsafe endpoints. The authors proposed 'strict-dynamic' to support nonce-based policies that do not depend on host lists.

That study explains why current guidance from OWASP and web.dev favors nonces and hashes over allowlists. It dates from 2016, so it does not tell you how many sites run a strict policy today. We did not run a new scan for this page.

We cite no CVE for this class, because a CSP weakness is a configuration flaw in the site, not a defect with its own identifier. PortSwigger's Web Security Academy has practice labs on policy bypass for readers who want to try it on its own training environment.

How do penetration testers evaluate a CSP?#

Testers read the policy first, then try to prove that the policy changes what an injection can do. Both parts need evidence. The method follows the lab's usual standard: direct requests to read the header, a real browser to see what it enforces, and controls to show that a result is real.

  1. Read the header on every response type. Request the home page, a logged-in page, an error page, a file download and an API route. Save each response with its headers. A policy on only some of these is a finding by itself.
  2. Run the policy through an evaluator. Google's CSP Evaluator marks unsafe keywords and risky hosts. Treat its output as a lead, not as proof.
  3. Check for each weakness in the next table. For each one, find the route or resource that makes it exploitable on this site.
  4. Prove the effect in a browser. The finding is not "the policy has unsafe-inline". The finding is "a script injected through this field ran, and here is the request, the page and the console". Label this evidence "Browser-observed".
  5. Use a control. Show the same injection against a response that has the stricter policy, or in Report-Only mode with the report log. If the control shows the first result was misleading, downgrade or drop the finding.

Testers look for these weaknesses:

Weakness Why it matters What proves it
'unsafe-inline' in script-src Inline injected script runs Injected inline script executes in the browser
'unsafe-eval' Code strings can run as script A sink such as eval or Function is reachable with page data
Wildcard or broad CDN host in script-src Anyone can host script on that host A file on that host, not under your control, loads and runs
JSONP or script-serving endpoint on an allowed host A callback parameter lets the attacker choose the script body A request to that endpoint returns attacker-chosen script text
Missing base-uri An injected <base> tag changes where relative scripts load from A relative script path resolves to another host
Missing object-src 'none' Plugin content can load <object> or <embed> with a data source loads
Missing form-action Injected forms post data off-site A form posts to a host you control in a test
Allowed host that no longer exists A takeover of that host serves script DNS for the allowed host does not resolve and can be registered

Keep proof payloads minimal and use only systems in the written scope. A policy test needs the same written authorization as any other test.

How do you roll out a CSP without breaking the site?#

Start in report-only mode, fix what the reports show, then enforce. The Report-Only header applies the policy without blocking. Violations still appear in the console and go to your endpoint. OWASP and Django both describe this as the safe first step, and you can send both headers at once: a strict Report-Only policy next to a looser enforced one.

  1. Write the strict candidate policy and send it as Content-Security-Policy-Report-Only on all HTML responses.
  2. Add a reporting directive. OWASP says report-to is the current directive and report-uri is deprecated, and suggests declaring both for older browsers.
  3. Collect reports for a full release cycle. Group them by directive and blocked URL.
  4. Remove inline scripts and handlers. Add nonces in templates. Add host sources only where a third-party script cannot take a nonce.
  5. Switch the header name to Content-Security-Policy. Keep the Report-Only header on a stricter version if you plan a further tightening.

A violation report is JSON. This is a fictional example for app.example.com:

{
  "csp-report": {
    "document-uri": "https://app.example.com/notes/42",
    "violated-directive": "script-src-elem",
    "blocked-uri": "inline",
    "disposition": "report"
  }
}

Reports can contain URLs with tokens in them. Treat the report endpoint as a log that holds sensitive data. Django's documentation also warns that 'report-sample' can expose sensitive data in reports.

How do you set a CSP in each stack?#

Set the header once, in the layer that sees every response. These snippets are from each project's documentation and use fictional hosts. Each starts in report-only mode, so test and then rename the header.

nginx. The always flag adds the header to error responses too, per the nginx headers module documentation. nginx cannot make a per-response nonce by itself, so this policy uses 'self' and needs all scripts as files.

add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;

Apache.

Header always set Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"

Node.js with Express and Helmet. Helmet sets a default policy (default-src 'self', object-src 'none', script-src 'self', frame-ancestors 'self' and others) and merges your directives into it. This is the nonce example from the Helmet docs:

app.use((req, res, next) => {
  res.locals.cspNonce = crypto.randomBytes(32).toString("hex");
  next();
});
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
      },
    },
  }),
);

Next.js. The Next.js guide sets the policy and nonce in proxy.ts (earlier versions use middleware.ts). Pages that use nonces must render dynamically, which turns off static caching for them. The guide lists the policy it recommends, with object-src 'none', base-uri 'self', form-action 'self' and frame-ancestors 'none'. Copy it from the guide instead of retyping it.

Python and Django. Django 6.0 added built-in support. Add django.middleware.csp.ContentSecurityPolicyMiddleware, then:

from django.utils.csp import CSP

SECURE_CSP_REPORT_ONLY = {
    "default-src": [CSP.SELF],
    "script-src": [CSP.SELF, CSP.NONCE, CSP.STRICT_DYNAMIC],
    "object-src": [CSP.NONE],
    "base-uri": [CSP.NONE],
    "report-uri": "https://app.example.com/csp-report/",
}

Enable the django.template.context_processors.csp context processor and set the nonce attribute of each script tag to the csp_nonce template variable. Django has no built-in report endpoint. Older Django versions need a third-party package.

Ruby on Rails. Edit config/initializers/content_security_policy.rb:

Rails.application.config.content_security_policy do |policy|
  policy.default_src :self, :https
  policy.object_src  :none
  policy.script_src  :self, :https
  policy.report_uri  "/csp-violation-report-endpoint"
end
Rails.application.config.content_security_policy_report_only = true

The Rails guide also describes nonces. Read the nonce section of the full guide for the exact settings, because the page excerpt we checked does not show them.

Java and Spring Security. Spring Security does not add a CSP by default. Its documentation says no reasonable default exists without knowing the application. Add it in the header configuration, and call .reportOnly() first:

http.headers((headers) -> headers
    .contentSecurityPolicy((csp) -> csp
        .policyDirectives("script-src 'self'; object-src 'none'; base-uri 'none'; report-uri /csp-report-endpoint/")
        .reportOnly()
    )
);

PHP and Laravel. Laravel has no built-in CSP header in the core framework documentation we checked. Add a middleware that makes a nonce per request, shares it with the view, and sets the header on the response:

public function handle($request, Closure $next)
{
    $nonce = base64_encode(random_bytes(16));
    view()->share('cspNonce', $nonce);
    $response = $next($request);
    $response->headers->set(
        'Content-Security-Policy-Report-Only',
        "script-src 'nonce-$nonce' 'strict-dynamic'; object-src 'none'; base-uri 'none'"
    );
    return $response;
}

In Blade, set the nonce attribute of each script tag to $cspNonce. This snippet is our own, based on the OWASP pattern, and is not taken from Laravel documentation.

What mistakes break a CSP most often?#

Most broken policies share the same five mistakes.

  • Using 'unsafe-inline' in script-src. It allows injected inline script. If you must keep inline code for now, use a nonce or hash and drop the keyword. A browser that supports nonces ignores 'unsafe-inline' when a nonce is present, so older browsers can keep working.
  • Allowing a large host. A host that serves user content or JSONP lets an attacker run a script from an allowed source. The 2016 Google study found unsafe endpoints on 14 of the 15 most allowed script domains.
  • Setting only default-src. base-uri, form-action and frame-ancestors do not fall back to it.
  • Using a meta tag for framing rules. frame-ancestors is ignored in a meta tag, per OWASP.
  • Sending the policy on some pages only. A static asset server or an error handler that skips the header leaves a gap.

CSP also covers framing. The frame-ancestors directive replaces X-Frame-Options for clickjacking defense, and the clickjacking page shows how to test it.

Testing checklist#

Use this list for each application, with written authorization for the target.

  • Every HTML response carries a policy, including errors and redirects with a body.
  • script-src has no 'unsafe-inline' without a nonce or hash, and no 'unsafe-eval'.
  • No wildcard, and no CDN or JSONP host that serves attacker-chosen script.
  • object-src, base-uri, form-action and frame-ancestors are set.
  • Nonces differ for every response, are not cached, and are not on injected tags.
  • Every allowed host still exists and is under the owner's control.
  • The report endpoint works and receives real violations.
  • An injection that reaches the page was tried in a real browser, and the result is saved with the console output.
  • A control shows the result holds against the intended policy.

When should you get a professional test?#

Get one when a CSP is part of how you defend a page that handles sensitive data, and you want proof that it holds. A scanner reads the header. A tester also looks for the injection points and allowed hosts that make a policy bypassable. Our web application penetration testing service checks the policy in a real browser, together with the injection flaws it is meant to contain, and each finding in the report links to its requests, responses and console evidence.

Questions

Is a CSP meta tag as good as the header?

Mostly, but not fully. OWASP says a meta tag works when you cannot set headers, for example on a CDN. Framing protection (frame-ancestors), sandbox and violation reporting do not work in a meta tag. Use the HTTP header when you can.

Does CSP replace output encoding?

No. CSP is a second layer. The flaw stays in the code, and a policy with gaps can be bypassed. Fix the injection with output encoding and treat CSP as damage control. The XSS page on this site covers the primary fix.

What is the difference between CSP and CORS?

CORS relaxes the same-origin policy so a page can read a response from another origin. CSP limits what the current page may load or do. CORS is about which sites may read your responses. CSP is about which sources your own pages may use.

Why does my policy break Google Tag Manager or analytics?

Those tools inject inline code and load more scripts from other hosts. With a nonce and strict-dynamic, give the loader script the nonce and the scripts it loads inherit trust. Without strict-dynamic you must list each host, which weakens the policy.

How do I find out what a new policy will break?

Send it as Content-Security-Policy-Report-Only first. The browser logs each violation in the console and sends reports to the endpoint you set. Fix the violations, then switch the header name to Content-Security-Policy.

Sources

  1. MDN: Content Security Policy (CSP) (checked 9 November 2026)
  2. W3C: Content Security Policy Level 3 (checked 9 November 2026)
  3. OWASP Cheat Sheet Series: Content Security Policy (checked 9 November 2026)
  4. PortSwigger Web Security Academy: Content security policy (checked 9 November 2026)
  5. web.dev: Mitigate cross-site scripting (XSS) with a strict Content Security Policy (checked 9 November 2026)
  6. Weichselbaum, Spagnuolo, Lekies, Janc: CSP Is Dead, Long Live CSP! (ACM CCS 2016) (checked 9 November 2026)
  7. Helmet documentation (Express) (checked 9 November 2026)
  8. Django 6.0 documentation: Content Security Policy (checked 9 November 2026)
  9. Next.js documentation: Content Security Policy (checked 9 November 2026)
  10. Spring Security reference: Servlet headers (checked 9 November 2026)
  11. Ruby on Rails Guides: Securing Rails applications (checked 9 November 2026)
  12. nginx documentation: ngx_http_headers_module (checked 9 November 2026)
  13. Google CSP Evaluator (checked 9 November 2026)