Integrating Security Headers with Accessibility Scanners
When a page serves Content-Security-Policy: script-src 'self', the browser refuses the axe-core <script> the scanner injects, axe is never defined, and the run reports zero violations — a false clean pass that looks like success. The fix is to disable Content-Security-Policy enforcement for the audit session only (Playwright’s bypass_csp=True) or to whitelist a per-run nonce, so the evaluation payload executes while the policy real users receive stays exactly as deployed. This page is one technique inside Security & Privacy Framework Integration, part of the broader Enterprise WCAG Audit Architecture & Standards Mapping strategy that turns compliance criteria into machine-executable rules.
When This Applies
This reconciliation is only relevant when the target enforces headers that intercept the scanner’s runtime operations. Confirm at least one of the following before reaching for a fix, because each blocks a different scanner mechanism:
script-srcwithout your nonce or origin. The most common blocker. Accessibility engines inject a script into the page context; a strictscript-src 'self'or a nonce-based policy rejects that injection outright, so the engine never loads.frame-ancestors/X-Frame-Options: DENY. Component-level harnesses that isolate a widget inside an iframe cannot frame the target, so per-component evaluations silently return an empty tree instead of the real component.Trusted Typesenforcement.require-trusted-types-for 'script'rejects the string-to-DOM sinks some injection helpers use, throwing before axe is defined.Referrer-Policy/ cookie flags gating the real page. Astrict-origin-when-cross-originreferrer or aSameSite=Strictsession cookie can cause the app to render a logged-out shell to the runner, so the scanner audits the wrong surface.
If the page serves none of these, header interference is not your problem — reconcile against the WCAG 2.2 vs 3.0 Success Criteria Taxonomy and look instead at premature DOM evaluation via dynamic content boundary detection.
How a Strict CSP Blinds the Scanner
The friction comes from how CSP governs script execution. An accessibility engine — almost always axe-core, whose active rule set is defined by your axe-core enterprise configuration — is delivered by appending a <script> element or evaluating a bundle in the page context. A production script-src 'self' directive that does not whitelist the scanner’s origin or the run’s generated nonce refuses that element. The browser logs Refused to execute inline script because it violates the following Content Security Policy directive, but the Playwright call that injected it does not raise, so the pipeline treats the empty result as a healthy page. The same directive is what makes frame-ancestors block iframe harnesses and truncate any evaluation that depends on crossing a Shadow DOM or lazy-loaded route boundary.
The critical property is that the failure is silent. A blocked injection and a genuinely clean page both produce an empty violations array, so the only reliable signal is to assert that the engine actually loaded before trusting its output.
Minimal Reproducible Example
The smallest way to see the false pass is to scan a page that carries a strict policy and a known violation, then check whether the engine is even defined. Serve any fixture with Content-Security-Policy: script-src 'self' and, say, an image missing its alt text, then run a context that does not bypass CSP:
from playwright.sync_api import sync_playwright
STRICT_URL = "https://staging.example.com/report" # serves script-src 'self'
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context() # CSP is enforced — injection will be blocked
page = context.new_page()
page.goto(STRICT_URL, wait_until="networkidle")
# Attempt to inject axe-core the way a scanner would.
page.add_script_tag(path="node_modules/axe-core/axe.min.js")
# The injected script was refused, so `axe` is undefined on window.
is_defined = page.evaluate("typeof window.axe !== 'undefined'")
print("axe loaded:", is_defined) # -> axe loaded: False
browser.close()The script prints axe loaded: False. A naive harness that skipped this guard and called axe.run inside a try block would swallow the ReferenceError, return no violations, and gate the deploy green while the missing-alt defect ships untouched.
Correct Implementation
Rather than weakening the deployed policy, tell the audit browser context to ignore the page’s Content-Security-Policy for the duration of the run. Playwright supports this natively with bypass_csp=True; the example below uses axe-playwright-python, which wraps the axe-core injection automatically, and captures the still-strict response header so the report records what the policy was:
from playwright.async_api import async_playwright
from axe_playwright_python.async_playwright import Axe
async def run_accessibility_audit(url: str) -> dict:
"""Audit a CSP-protected page without weakening the deployed policy."""
axe = Axe()
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
# bypass_csp=True tells the browser context to ignore the page's
# Content Security Policy so the scanner payload can execute. This
# affects only the audit context; the deployed policy is unchanged.
context = await browser.new_context(
bypass_csp=True,
java_script_enabled=True,
viewport={"width": 1280, "height": 800},
)
page = await context.new_page()
captured = {}
def capture_headers(response):
if response.url == url:
captured.update(response.headers)
page.on("response", capture_headers)
await page.goto(url, wait_until="networkidle")
# Fail loudly if the engine did not load, instead of reporting clean.
if not await page.evaluate("typeof window.axe !== 'undefined'"):
raise RuntimeError(f"axe-core failed to inject on {url}")
# The scanner runs despite the strict CSP the server still sends.
results = await axe.run(page)
await browser.close()
return {
"violations": results.response["violations"],
"csp_header": captured.get("content-security-policy"),
}bypass_csp disables enforcement for this session only — it does not touch the DOM, the accessibility tree, or which rules run, so the evaluation is identical to a page served with a permissive policy. For a genuine production probe where you cannot relax the policy even for one session, whitelist a per-run nonce instead: generate a nonce, inject the engine with a matching nonce attribute, and have the origin echo that value in script-src 'nonce-…' for the scanner’s requests. When a route cannot execute the injected script at all, hand traversal to the fallback routing for JS-disabled crawlers path so the server-rendered markup is still evaluated.
The audit sequence below shows the scanner opening a bypass_csp context, navigating, capturing the still-strict response headers, and running the evaluation payload:
CI/CD Pipeline Integration
In the pipeline this technique sits between navigation and gating, and it needs a two-tier threshold so header noise never fails a build the way a real defect should. Treat confirmed accessibility violations — missing alt, insufficient contrast, broken keyboard order — as hard failures that halt the deploy, and treat CSP-blocked, iframe-denied, or evaluation-failed telemetry as soft warnings that trigger one automatic retry with the corrected context flags before escalating to QA. Filter scanner logs for evaluation-failed, timeout, and incomplete-dom markers, cross-reference them against the network waterfall to separate a genuine violation from a blocked injection, then route the sanitized report through a shared contract — the schema discipline is documented in JSON Schema validation for accessibility data — and archive it under your Audit Data Storage & Retention Policies. Weigh the severity of any surfaced issue against the A/AA/AAA compliance level mapping before it blocks a merge.
Gotchas
- Authenticated multi-tenant routes need the session, not just the bypass.
bypass_cspunblocks the injection but not the login wall. If the tenant’s page renders a shell to an unauthenticated runner, seed the auth cookie and consent state in the same context first, or every route surfaces the same handful of banner violations instead of the real surface. frame-ancestorsoutlivesbypass_cspin a real production probe.bypass_cspdisables enforcement inside the audit context, but if your harness frames the target from a separate parent origin you do not control,frame-ancestorsstill applies there. Scan the route directly rather than nesting it in an iframe, or dropframe-ancestorson the production-mirror only.- Viewport variance changes which rules fire. A responsive layout can hide the nav behind a hamburger at a narrow viewport, so
landmark-one-mainorcolor-contrastfindings appear or vanish with width. Pin the viewport (as in the corrected code) and run the same fixed sizes in local and CI so a header-reconciled scan stays reproducible rather than flapping between runs.
Frequently Asked Questions
Does bypass_csp change the violations axe-core returns?
No. bypass_csp only disables Content-Security-Policy enforcement for the audit session so the axe-core script can be injected. It does not alter the DOM, the accessibility tree, or which rules run — the evaluation is identical to a page served with a permissive policy. It exists purely to stop a strict script-src from silently blocking the engine and manufacturing a false clean pass.
My CSP disables unsafe-eval — will axe-core still inject?
Yes. axe-core does not depend on eval, so an unsafe-eval restriction does not affect it. The directive that actually blocks the scanner is script-src, which governs whether the injected <script> element is allowed to load. Reconcile that with bypass_csp or a nonce and leave unsafe-eval disabled.
The scan reports zero violations only in CI behind the WAF. Why?
The runner most likely reached a bot-mitigation challenge or block page rather than the application, so axe evaluated an empty interstitial — not a CSP problem at all. Confirm by dumping page.title() in CI, then allow-list the runner’s egress IP range and the scanner user agent in the WAF, or route the audit through an authenticated bypass. Never widen the WAF or the CSP for everyone just to make one scan pass.
Should I set ignore_https_errors=True alongside bypass_csp?
No. Keep ignore_https_errors=False. Disabling TLS verification to quiet a staging-certificate warning masks real mixed-content and certificate defects, and it is a separate concern from CSP. If a staging environment genuinely uses a self-signed certificate, pin that specific certificate rather than turning off verification for the whole fleet.