Build a real-browser CSP matrix that distinguishes reporting from blocking, checks directive fallback and multiple headers, and records release evidence.

An AI assistant can turn a list of domains into a polished Content-Security-Policy header in seconds. The header may be parseable, use familiar directives, and produce reassuring console messages. None of those facts proves that the intended page still works or that the unwanted behavior is actually blocked.

Content Security Policy is enforced by a browser against a particular response, document, resource request, and execution path. A review therefore needs more than a linter. It needs a small application that serves deliberate policies, a matrix of allowed and denied actions, and real-browser observations that show what executed, what left the page, which policy arrived, and which decision the browser made.

This guide builds that evidence without claiming that CSP cures cross-site scripting. CSP is defense in depth. Continue to prevent injection with context-appropriate output encoding, sanitization, safe DOM APIs, framework protections, dependency review, and prompt repair of XSS defects. The browser matrix answers a narrower question: does this exact policy, on these exact routes and browser versions, enforce the approved resource contract without breaking required behavior?

Freeze the Page Contract Before Drafting Directives

Start outside the AI conversation. Inventory the routes that will receive CSP and the resources each route actually needs. Separate a marketing page, signed-in application, checkout, embedded widget, report viewer, and error document if their execution graphs differ. A policy copied from the homepage may quietly break authentication or allow a third-party origin that only one route requires.

For every route, name the intended script entry points, styles, images, fonts, connections, frames, workers, form destinations, and embedding parents. Record whether HTML is generated per response or served as immutable static output. Also record browser support, rollout owner, observation window, acceptable breakage, and the response layer that owns the header. A reverse proxy and an application can each add a CSP; that is not harmless duplication.

Contract surfaceExample decisionEvidence to preserve
Script entryOne server-rendered bootstrap receives a fresh nonce; event-handler attributes and eval() are forbidden.Response nonce, matching element, DOM sentinels, and console violations.
NetworkThe app may call its own API and one named telemetry origin.Observed requests and responses, blocked attempts, and the final connect-src.
NavigationForms submit only to the application; injected base URLs must not retarget relative links.Safe local form sink, final document base URL, and explicit form-action/base-uri.
FramingThe checkout cannot be embedded; one widget may frame a named child.Parent and child routes, frame attach/load result, and frame-src/frame-ancestors.
RolloutA stricter candidate is observed before it replaces the currently enforced policy.Both header sets, disposition on violations, test version, reviewer, and RELEASE/HOLD result.

Do not start with “allow whatever the page currently loads.” That can preserve an accidental dependency or an injected request. Begin with approved behavior, compare it with observed behavior, and investigate every difference.

Separate Monitoring From Blocking

The W3C Content Security Policy Level 3 Working Draft defines two different response headers. Content-Security-Policy is enforced. Content-Security-Policy-Report-Only monitors a policy but does not block the action. A Report-Only console message proves that the candidate noticed an action; it does not prove that the action was stopped.

That distinction needs a behavioral assertion. Put a unique DOM sentinel in each fixture script. Under Report-Only, an otherwise permitted browser action should still set its sentinel even when a violation is recorded. Under enforcement, the denied script must leave the sentinel unset. For a network request, check whether the local destination actually received it. Do not infer blocking from the presence of a red console line.

Report-Only cannot be delivered through a meta http-equiv element. The CSP3 draft also excludes report-uri, frame-ancestors, and sandbox from meta-delivered policies. A meta policy begins applying only after the element is parsed, so earlier content is outside it. Use response headers for the production matrix and capture those headers from the main-document response.

A site can run a current enforced policy beside a stricter Report-Only candidate. Label them separately in evidence. Moving the candidate to enforcement is a configuration change that requires another complete run; it is not justified merely because reports became quiet.

Make Fallback and Multiple-Policy Semantics Visible

default-src is a fallback for specified fetch directives, not a universal default for every CSP control. If a more specific directive exists, it governs that resource type. Navigation and document directives such as form-action, base-uri, and frame-ancestors do not become restrictive because default-src 'none' appears elsewhere.

Browser actionRelevant lookupFixture requirement
External script elementscript-src-elem, then script-src, then default-srcTry same-origin and second-origin script URLs.
Inline event handlerscript-src-attr, then script-src, then default-srcClick a harmless button and inspect its sentinel.
Fetch or WebSocketconnect-src, then default-srcUse local destinations and record whether a request arrives.
Workerworker-src, then child-src, script-src, and default-srcAdd a separate worker suite if the application creates workers.
Form submissionform-action; no default-src fallbackSubmit only to a disposable local sink.
Document base URLbase-uri; no default-src fallbackInsert a second-origin base and inspect document.baseURI.
Who may embed this pageframe-ancestors; no default-src fallbackLoad the protected child from approved and denied parents.

Also distinguish frame-src, which controls what this page may load in a frame, from frame-ancestors, which controls who may embed this page. Testing only one direction leaves the other claim unproved.

When a response contains multiple enforced policies, the browser enforces every one. An action must pass all of them, so adding a second header can narrow the result but cannot relax the first. For example, one policy that permits same-origin scripts and another that permits only a different CDN does not create a union; a script allowed by only one still fails. Capture header values as an array rather than a single combined string, then include an intentional two-header case in the matrix.

Serve a Two-Origin Fixture Instead of Testing Production First

Create two local HTTP origins on different ports. The application origin serves one HTML fixture per case, one external script, a frame parent and child, and a form receiver. The second origin serves a cross-origin script, frame, API response, and form receiver. Use synthetic strings only; no account, production cookie, customer payload, third-party endpoint, or public callback is needed.

Each fixture should place a unique sentinel in the DOM only after its action succeeds. A same-origin script might set data-same-origin="ran"; a nonce-bearing inline bootstrap can set data-nonce="ran"; a cross-origin response can return a fixed marker. An eval() fixture should evaluate only a constant that sets another sentinel. It exists to prove whether 'unsafe-eval' is required, not to demonstrate dynamic execution with untrusted text.

Generate a cryptographically strong nonce for every HTML response that uses a nonce policy. Put that response-specific value in both the header and the intended script element, and retain only a redacted or hashed evidence value if logging the nonce would create unnecessary exposure. Reusing one nonce across responses defeats the “number used once” contract. For static HTML, a hash-based strict policy may fit better because the approved inline bytes can be fixed at build time; changing whitespace or code then requires a new hash.

Keep malformed-policy cases separate from authorization cases. A browser can parse a header while ignoring an unknown directive or invalid source expression. “The header exists” and “the browser emitted no parser warning” are weak checks. The matrix must show the required path ran and the prohibited neighboring path did not.

Capture Five Evidence Channels With Playwright

Run each case in a fresh browser context so service workers, caches, storage, and prior navigation do not carry state forward. Playwright’s bypassCSP option defaults to false; set it explicitly to false and assert the harness configuration in the result. Turning it on makes a CSP enforcement test meaningless.

The following skeleton expects the local fixture server to expose the paths in cases. Each fixture also needs a policy-authorized controller that sets data-csp-complete="true" only after its named local actions and receiver checks have settled. That marker synchronizes the harness; it is not the security result. The harness captures main-response header lines, DOM sentinels, securitypolicyviolation events, console output, and network activity. Expand the expected sentinels and requests for each contract rather than snapshotting a whole noisy console.

import { chromium, firefox, webkit } from 'playwright';

const appOrigin = 'http://127.0.0.1:4310';
const secondOrigin = 'http://127.0.0.1:4320';
const cases = [
  { name: 'report-only-inline', path: '/report-only-inline' },
  { name: 'enforced-inline', path: '/enforced-inline' },
  { name: 'nonce-match', path: '/nonce-match' },
  { name: 'nonce-mismatch', path: '/nonce-mismatch' },
  { name: 'directive-fallback', path: '/directive-fallback' },
  { name: 'two-enforced-headers', path: '/two-enforced-headers' },
  { name: 'base-and-form', path: '/base-and-form' },
  { name: 'frame-parent', path: '/frame-parent' },
];

const engines = { chromium, firefox, webkit };
const results = [];

for (const [engineName, engine] of Object.entries(engines)) {
  const browser = await engine.launch();

  for (const fixture of cases) {
    const context = await browser.newContext({ bypassCSP: false });
    await context.addInitScript(({ allowedFrameOrigins }) => {
      globalThis.__cspViolations = [];
      globalThis.__cspFrameMessages = [];
      document.addEventListener('securitypolicyviolation', (event) => {
        globalThis.__cspViolations.push({
          blockedURI: event.blockedURI,
          disposition: event.disposition,
          effectiveDirective: event.effectiveDirective,
          originalPolicy: event.originalPolicy,
          sourceFile: event.sourceFile,
        });
      });
      window.addEventListener('message', (event) => {
        if (!allowedFrameOrigins.includes(event.origin) ||
            event.data?.type !== 'csp-fixture-ready' ||
            typeof event.data.fixture !== 'string') return;
        globalThis.__cspFrameMessages.push({
          origin: event.origin,
          fixture: event.data.fixture,
        });
      });
    }, { allowedFrameOrigins: [appOrigin, secondOrigin] });

    const page = await context.newPage();
    const consoleMessages = [];
    const requests = [];
    const failedRequests = [];

    page.on('console', message => consoleMessages.push({
      type: message.type(),
      text: message.text(),
    }));
    page.on('request', request => requests.push(request.url()));
    page.on('requestfailed', request => failedRequests.push({
      url: request.url(),
      failure: request.failure(),
    }));

    const response = await page.goto(appOrigin + fixture.path, {
      waitUntil: 'load',
    });
    if (!response) throw new Error('missing main response: ' + fixture.name);

    await page.locator('[data-csp-action]').evaluateAll(elements => {
      for (const element of elements) element.click();
    });
    await page.locator('[data-csp-complete="true"]').waitFor({
      state: 'attached',
      timeout: 2_000,
    });

    const headerLines = (await response.headersArray()).filter(header =>
      header.name.toLowerCase() === 'content-security-policy' ||
      header.name.toLowerCase() === 'content-security-policy-report-only' ||
      header.name.toLowerCase() === 'reporting-endpoints'
    );
    const pageEvidence = await page.evaluate(() => ({
      baseURI: document.baseURI,
      sentinels: { ...document.documentElement.dataset },
      violations: globalThis.__cspViolations,
      frameMessages: globalThis.__cspFrameMessages,
    }));
    const frameEvidence = page.frames().map(frame => ({
      name: frame.name(),
      url: frame.url(),
    }));

    results.push({
      engine: engineName,
      browserVersion: browser.version(),
      bypassCSP: false,
      case: fixture.name,
      headerLines,
      pageEvidence,
      frameEvidence,
      consoleMessages,
      requests,
      failedRequests,
    });
    await context.close();
  }

  await browser.close();
}

console.log(JSON.stringify(results, null, 2));

Adapt interactions to the fixture. A form submission can navigate away, so target a named local frame or capture the request before navigation. A frame-ancestors case must inspect the embedding parent and the protected child response. Cross-origin frame contents are not directly readable; use frame lifecycle, response, console, and a safe postMessage sentinel designed into the fixture.

Do not treat the five channels as interchangeable. A missing network request supports a blocking result, but a typo in the fixture URL can look the same. A DOM sentinel proves execution, but not which policy permitted it. A violation event identifies a directive, but reporting behavior can differ across engines. The response headers prove delivery, not effect. Require the expected combination.

Run an Explicit Allow-and-Deny Matrix

Every allowed case needs a denied neighbor. If a nonce-bearing inline script runs, a script with a missing or different nonce must not. If same-origin script loading is required, the same fixture should attempt a second-origin script that is outside the contract. If a form target is approved, submit to an unapproved local target as well.

Write expected results before launching the browser. For each row, state which sentinel must appear, which receiver may see a request, which effective directive should be reported, and whether the disposition should be report or enforce. An unexpected success and an unexpected denial are both failures. If the expected file is derived after observing the run, the matrix can simply memorialize whatever the browser happened to do.

CaseExpected browser behaviorRequired evidence
Report-Only inline scriptScript runs; monitored violation is observable.Sentinel present, disposition reported, Report-Only header captured.
Enforced inline scriptScript does not run.Sentinel absent, enforced violation, no misleading success marker.
Same-origin vs cross-origin scriptOnly origins in the exact effective policy run.Separate sentinels plus request/response ledger.
eval()Constant evaluation is blocked unless explicitly and intentionally permitted.Sentinel, console, and effective script-src decision.
Matching vs stale nonceOnly the response-matched nonce runs.Two responses with different nonce fingerprints and opposite sentinels.
Specific directive fallbackBehavior follows the documented chain, not a guessed universal fallback.Policy/header, effective directive, and action result.
Two enforced headersThe result is the intersection; neither header relaxes the other.Both raw header lines and denied actions allowed by only one policy.
base-uri and form-actionAn unapproved base or form destination cannot redirect the safe local flow.document.baseURI, receiver ledger, and violations.
frame-src and frame-ancestorsChild loading and parent authorization match their separate contracts.Parent/child headers, frame lifecycle, safe message sentinel.

Run the matrix in every browser engine the product claims to support and store exact browser and Playwright versions. A disagreement is a finding, not an invitation to average results. Check the current CSP3 draft, implementation status, and your browser-support policy before deciding whether to narrow support, add a compatible fallback, or hold the rollout.

Repeat critical cases through the production-like delivery stack before release. A local application can emit one header while a proxy appends another, a CDN caches HTML with the wrong response nonce, or an error handler omits policy entirely. Probe representative success, redirect, authentication, validation-error, not-found, and server-error responses without causing real business effects. Capture the final response as the browser received it, not only the value in an application configuration file.

Keep test failures diagnostic. A row should say “cross-origin script sentinel ran under enforced case” or “second CSP header missing on error route,” not merely “security test failed.” Preserve the route, policy revision, browser, observed actions, and artifact hashes needed to reproduce the result, while excluding secrets and customer data.

Do Not Use Violation Reports as the Sole Oracle

The W3C Reporting API Working Draft describes delivery as best effort and explicitly says it is not a reliable communication channel. A browser may delay, reject, or never deliver a report. Network conditions, privacy controls, page lifetime, endpoint configuration, or browser implementation can all affect arrival.

That makes server-side reports useful operational signals, but unsuitable as the only test assertion. Preserve immediate document events where supported, console output, network observations, DOM sentinels, and the delivered headers. Test the reporting endpoint and its content type separately, minimize report retention, restrict access, and avoid treating a quiet dashboard as proof of zero violations.

Reports can include document and blocked-resource information. Define collection, access, redaction, retention, and deletion before enabling them broadly. Do not send sensitive URL components or internal fixture data to an unapproved third party merely because an AI-generated example used that endpoint.

Keep the Release Claim Bounded

This matrix does not cover every browser feature. If the application uses dedicated workers, shared workers, service workers, WebAssembly, blob URLs, sandboxed frames, Trusted Types, browser extensions, legacy clients, or complex redirect chains, add focused cases before making claims about them. Worker policy has its own fallback chain, and a service worker can affect request observations; neither should be smuggled into a generic “CSP passed” statement.

Define the rollout boundary too. A low-risk documentation route may move from Report-Only to enforcement before a stateful application route, but that result cannot be copied forward as proof. Canary by named route or response class, watch both functional and security signals, and keep a fast path to the last reviewed policy. Do not respond to breakage by adding *, 'unsafe-inline', or 'unsafe-eval' without a new threat and compatibility review.

Static analysis still helps. Lighthouse and CSP Evaluator can flag syntax and policies likely to be bypassed, while Chrome’s strict-CSP guidance explains nonce- and hash-based approaches. Use those as additional reviewers, not substitutes for the behavior matrix. A strict-looking policy can still break an application, and a green browser matrix cannot prove there is no XSS vulnerability.

Assign owners to the final decision. The application owner confirms required behavior and route coverage. Security reviews injection defenses, policy strength, nonce or hash lifecycle, reporting privacy, and omitted features. Platform engineering confirms exactly where headers are added and that caches preserve the response-specific contract. QA owns the browser matrix and evidence integrity. A release owner records RELEASE or HOLD for one policy revision and destination.

FindingDecisionNext action
Required sentinel is absent under enforcementHOLDTrace the effective directive; approve a narrow change or remove the dependency.
Denied action runs, reaches its receiver, or embeds successfullyHOLDCorrect the policy or fixture and retain the case as a regression.
Only Report-Only was testedHOLDRepeat the complete matrix with the release candidate enforced.
Multiple headers, nonce reuse, or response-layer ownership is unresolvedHOLDResolve delivery and rerun from a clean browser context.
All approved allow/deny pairs pass in supported browsersEligible for RELEASERecord scope, residual risk, rollout monitoring, rollback, and named approval.

After release, monitor functional errors and violations, then rerun after changes to templates, scripts, third parties, proxies, CDNs, framework builds, browser support, or reporting configuration. Rollback should restore a known policy revision without disabling output encoding, sanitization, or other XSS controls. CSP is one layer; do not trade away the layers beneath it to make a generated header easier to ship.

Primary Sources and Scope

Sources were checked on August 27, 2026. The examples use synthetic local origins and harmless sentinels. They do not authorize testing an unowned site, collecting production reports, weakening a live policy, or declaring an application free of XSS.

Review Your Draft in One Workspace

Check AI-likelihood signals, revise structure and tone, and review the result before you publish.

Open AI Humanizer