How to Fix a Missing or Weak Content-Security-Policy Header (Step by Step)
AppCheck flags CSP as "Warning" on roughly 6 in 10 first-time SaaS audits — usually because the header is missing, set to report-only, or broken by an inline script nobody owns. If you need to know how to fix missing Content-Security-Policy header fast, the shortest path is: deploy a permissive Content-Security-Policy starter policy in report-only mode, watch the violation reports stream in for 48 hours, then flip to enforce with nonce-based CSP that whitelists exactly the scripts your app actually loads. This walkthrough covers the exact nginx, Apache, Cloudflare and Next.js snippets to do it without breaking your checkout flow, plus the AppCheck re-test that converts "Warning" into "Pass" on your security score.
Why CSP shows up as a warning, not a pass
A Content-Security-Policy header is a single HTTP response header that tells the browser which scripts, styles, images and connections are allowed to execute on a given page. When the header is missing, the browser falls back to "allow everything that loads," which means a single reflected XSS in a comment field becomes account takeover. Scanners flag it as a Warning rather than a Pass for one of three reasons:
- No header at all. The server, CDN or framework never sends
Content-Security-Policy. This is the most common cause on small Node apps and static sites hosted on bare nginx. - Header set to
Content-Security-Policy-Report-Only. Report-only is a dry run — the browser logs violations but does not block them. Scanners treat report-only exactly like "no policy." - Header present but trivially broken. The classic example is
default-src 'unsafe-inline', which is functionally equivalent to no policy because it permits every inline script an attacker might inject.
You will also see Warning when the policy mixes https: with 'unsafe-inline' and 'unsafe-eval', or when script-src is left off entirely and the browser falls back to default-src 'self', then a marketing tag injected via Tag Manager gets blocked on the first deploy. Knowing which of these is breaking you is the whole point of the next step.
Start in report-only mode and watch /csp-report for 48 hours
Before you tighten anything, deploy a permissive policy in report-only and collect violations. This is the single best way to discover every script, image, font and frame your real users load, including the ones you forgot about. A reasonable starter looks like this:
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https:; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https:; font-src 'self' data: https:; connect-src 'self' https:; frame-src 'self' https:; report-uri /csp-report
Three things to do while it runs:
- Point
report-uri(or the modernreport-todirective) at an endpoint that stores JSON. A 50-line Express handler that appends to a log file is enough for a 48-hour window. If you use a SaaS endpoint, make sure it accepts theapplication/csp-reportcontent type. - Tag a unique
report-togroup name so you can distinguish staging from production violations, and add adefault-srcentry on every subdomain you operate. - Watch for repeat offenders. Group violations by
blocked-uriandviolated-directive. Anything that fires more than five times in 48 hours is real production traffic you must allowlist before enforcement — usually a payments SDK, an analytics snippet, a chat widget or a CDN-hosted webfont.
Most teams that skip this step and go straight to a strict policy break their own checkout within an hour. The 48-hour report window is what separates a CSP common mistakes post-mortem from a clean ship.
Ship a starter CSP and tighten with nonces
Once the report log has cooled, replace the report-only header with an enforced policy and remove the dangerous 'unsafe-inline' and 'unsafe-eval' keywords. This is where nonce-based CSP earns its keep: instead of allowlisting every inline script by hash, you generate a fresh random nonce per request, render it into every <script> tag, and echo it in the header. The browser refuses any inline script whose nonce does not match.
A tight production starter looks like this:
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-RANDOMBASE32' 'strict-dynamic'; style-src 'self' 'nonce-RANDOMBASE32'; img-src 'self' data: https://images.example.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://api.example.com; frame-src https://js.stripe.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests
A few decisions worth calling out, because they are the CSP common mistakes that bite teams in production:
- Use
strict-dynamicwith nonces. It lets scripts you trust load further scripts (Stripe, GA, Segment) without you having to enumerate every transitive CDN. Modern browsers ignorehttps:and'self'inscript-srcwhenstrict-dynamicis present, so you do not have to keep two allowlists in sync. - Lock
object-srcandbase-uritonone/'self'. Flash is dead, but<object>is still abused for mixed-content attacks, and a hostile<base>tag rewrites every relative URL on the page. - Set
frame-ancestors 'none'unless you deliberately allow embedding. This replaces theX-Frame-Optionsheader and is the only directive that protects against clickjacking in modern browsers. - Keep
upgrade-insecure-requestsuntil you have audited every mixed-content asset. Removing it too early re-introduces the exact downgrade attacks CSP is meant to prevent.
If your stack cannot generate per-request nonces — a fully static site, for example — fall back to hash-based CSP: ship a build step that hashes every inline <script> and <style> body, then add those SHA-256 hashes to the header. It is more work than nonces but it survives cache layers and CDNs cleanly.
Per-framework snippets: nginx, Apache, Cloudflare, Next.js
These are the exact snippets to drop into each layer. Stack them in order — origin first, CDN second — so you do not double-add the header.
Content-Security-Policy nginx
For a server block serving your app:
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'nonce-$nonce' 'strict-dynamic'; style-src 'self' 'nonce-$nonce'; img-src 'self' data: https:; connect-src 'self' https://api.example.com; frame-src https://js.stripe.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
Set always; so nginx still emits the header on 4xx and 5xx responses, and generate the nonce in your app or via sub_filter so every <script> tag receives nonce="$nonce".
Content-Security-Policy on Apache
In your .htaccess or virtual host:
<IfModule mod_headers.c>
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'nonce-%{UNIQUE_ID}e' 'strict-dynamic'; style-src 'self' 'nonce-%{UNIQUE_ID}e'; img-src 'self' data: https:; connect-src 'self' https://api.example.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
</IfModule>
Apache's %{UNIQUE_ID}e is a per-request identifier; pair it with a mod_security rewrite or a small mod_substitute rule that injects the matching nonce into every <script> tag.
Cloudflare
In the dashboard, go to SSL → Edge Certificates → Content-Security-Policy (or use a Transform Rule) and paste the same enforced policy. Cloudflare will append it on every response that flows through the edge, which is the right place to lock frame-ancestors and upgrade-insecure-requests because they protect against clickjacking and mixed content even when your origin is briefly misconfigured.
Next.js
In next.config.js, set a per-request nonce via the headers() function so middleware can stamp it onto every server-rendered page:
async headers() {
return [{
source: '/(.*)',
headers: [{
key: 'Content-Security-Policy',
value: "default-src 'self'; script-src 'self' 'nonce-{nonce}' 'strict-dynamic'; style-src 'self' 'nonce-{nonce}'; img-src 'self' data: https:; connect-src 'self' https://api.example.com; frame-src https://js.stripe.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
}]
}];
}
Then in middleware.ts, generate the nonce, attach it to req.headers, and have your server components render <script nonce={nonce}> directly. Next 13+ supports this pattern without third-party plugins.
After every change, remember the CSP report-only vs enforce distinction: a header named Content-Security-Policy-Report-Only will never block a script, so scanners keep flagging Warning. Confirm the bare Content-Security-Policy header (no -Report-Only) is the one your origin emits before you move on.
Re-test with AppCheck and confirm Pass
Once the enforced header is live on all four layers — origin, CDN, framework, and any reverse proxy in between — re-scan the production hostname with AppCheck. Two signals confirm the fix:
- The CSP row in the AppCheck report flips from Warning to Pass, with the exact directive list you shipped echoed back so you can audit it.
- Your security score climbs, because CSP is weighted alongside HSTS, X-Frame-Options and Referrer-Policy in the scoring engine.
If it still reads Warning, the usual culprits are a CDN cache serving a stale header, a staging environment leaking through the same hostname, or a second origin (an old Heroku dyno, a legacy WordPress install on /blog) that does not inherit the same config. Purge caches, hit the URL with curl -I from a clean IP, and confirm the response line is Content-Security-Policy: …, not Content-Security-Policy-Report-Only: …. When AppCheck reads Pass, attach the export to your next product security review and stop fielding the same questionnaire every quarter.
Frequently asked questions
What is the difference between CSP report-only vs enforce?
Report-only (Content-Security-Policy-Report-Only) tells the browser to log violations to report-uri but still execute every script. Enforce (Content-Security-Policy) actively blocks anything that violates a directive. AppCheck and most other scanners treat report-only as Warning because it does not reduce risk.
Can I use 'unsafe-inline' if I also use nonces?
No. Modern browsers ignore 'unsafe-inline' when a nonce or hash is present, but 'unsafe-inline' defeats the entire purpose of CSP if it is the only mechanism you rely on, because any attacker-injected <script> tag inherits the same trust. Pair 'nonce-...' with 'strict-dynamic' and drop 'unsafe-inline' for a real defence.
Do I still need X-Frame-Options if I set frame-ancestors 'none'?
No. frame-ancestors supersedes X-Frame-Options in every browser shipped since 2014. Keep frame-ancestors in CSP and remove the legacy header to avoid conflicting signals.
How long should I run report-only before enforcing? 48 hours of real production traffic is the minimum to catch weekly cron jobs, weekend ETL jobs and users in distant time zones. For low-traffic sites or staging environments, extend to a full week and watch the violation log for new blocked URIs before you flip the switch.
Will CSP break my analytics or marketing tags? Only if you enforce a strict policy without checking the report log first. The report-only window exists specifically to surface every third-party tag your site loads — Segment, GTM, Hotjar, Intercom — so you can allowlist the right CDNs before enforcement.
Run AppCheck to confirm your CSP reads Pass, then ship the report to procurement.
Sources
- Content Security Policy (CSP) — MDN
- Content Security Policy Cheat Sheet — OWASP
- CSP Level 3 — W3C Working Draft
- Strict CSP — Google Web Fundamentals
- mod_headers — Apache HTTP Server documentation
- HTTP Headers — nginx documentation
- Content-Security-Policy header — Cloudflare documentation
- Next.js
headers()configuration reference