GuideTechnical SEO

Content-Security-Policy example for a SaaS, with a safe rollout

A nonce-based starter Content-Security-Policy for a SaaS, the report-only rollout that shows every violation first, and the Next.js proxy setup.

LaunchScalerPublished 8 min read

A good starter Content-Security-Policy for a SaaS allows scripts only by a per-request nonce, blocks plugins and framing, and names every other source your app loads: default-src 'self'; script-src 'nonce-{random}' 'strict-dynamic'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'. Ship it first as Content-Security-Policy-Report-Only, so the browser reports every violation without blocking anything, then switch to the enforcing header once the reports are clean.

That order matters more than the exact policy. An enforced CSP that is wrong breaks sign-up, checkout or analytics the moment it deploys; a report-only one tells you what would have broken.

What does a starter Content-Security-Policy look like?

Start from the strict, nonce-based policy web.dev recommends and add the non-script sources your pages load. The policy below is a report-only starting point for a typical SaaS app: your own origin, inline styles, images from anywhere over HTTPS, and API calls to your own backend.

Reporting-Endpoints: csp-endpoint="https://yourapp.com/api/csp-report"
Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self';
  connect-src 'self';
  frame-src 'self';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';
  upgrade-insecure-requests;
  report-uri /api/csp-report;
  report-to csp-endpoint

The header is one line in practice; it is split here for reading. What each directive does:

DirectiveWhat it controlsWhy this value
default-src 'self'Fallback for any fetch directive you do not listAnything unlisted must come from your origin
script-src 'nonce-...' 'strict-dynamic'Which scripts runOnly scripts carrying this response's nonce, plus scripts they load
https: 'unsafe-inline' in script-srcFallback for old browsersBrowsers that support 'strict-dynamic' ignore both, per web.dev
style-src 'self' 'unsafe-inline'Stylesheets and inline stylesMost UI libraries inject inline styles; tighten later
img-src 'self' data: https:ImagesAvatars and user content often come from other hosts
connect-src 'self'fetch, XHR, WebSocket targetsAdd your API, analytics and error-tracking origins
object-src 'none'Plugins (<object>, <embed>)Nothing modern needs them
base-uri 'self'The <base> tagStops an injected <base> rewriting your relative URLs
form-action 'self'Where forms may submitStops injected forms posting elsewhere
frame-ancestors 'none'Who may frame your pagesClickjacking protection; replaces X-Frame-Options
upgrade-insecure-requestshttp:// subresourcesBrowser upgrades them to https://
report-uri and report-toWhere violation reports goBoth, so older and newer browsers report

How does Mozilla Observatory score a Content-Security-Policy?

The Observatory takes 25 points for no CSP, 20 for 'unsafe-inline' or data: in script-src, and 10 for 'unsafe-eval', from a starting score of 100. A policy with none of those earns 5 bonus points, and one built on default-src 'none' with a locked-down form-action earns 10. A report-only header on its own scores the same as no CSP.

What the Observatory findsModifier
No CSP header-25
Only Content-Security-Policy-Report-Only-25
Header cannot be parsed-25
'unsafe-inline' or data: in script-src, or https: in script-src or object-src-20
An http: source for scripts or other active content on an HTTPS site-20
'unsafe-eval'-10
An http: source for images or media only-10
Unsafe sources in style-src only0
No unsafe sources+5
default-src 'none', no unsafe sources, form-action set to 'none' or 'self'+10

Two details from the Observatory's CSP analyzer explain why the starter policy above is not penalised for its fallbacks. It drops 'unsafe-inline' from script-src when a nonce or hash is present, and when 'strict-dynamic' sits beside a nonce it also drops https: and 'self' before scoring. Without the nonce, those same tokens cost you 20 points.

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

Deploy the policy as report-only, collect violation reports from real traffic, fix or allow each source they name, and only then send the enforcing header. MDN describes the report-only header as the way "to test or repair violations before a specific Content-Security-Policy is applied and enforced."

  1. List what your pages load. Open the Network panel on your main page types (marketing page, sign-up, the app, checkout) and note every script, style, font, image, frame and API origin.
  2. Add a report endpoint. Define it with Reporting-Endpoints: csp-endpoint="https://yourapp.com/api/csp-report", then reference it with report-to csp-endpoint. Keep report-uri beside it: MDN marks report-uri as deprecated but advises sending both until report-to is broadly supported, and browsers that support report-to ignore report-uri.
  3. Accept both report formats at that endpoint. report-uri posts Content-Type: application/csp-report with a csp-report object (blocked-uri, effective-directive, document-uri); report-to posts application/reports+json with type: "csp-violation" and a body (blockedURL, effectiveDirective, documentURL, disposition). Return 204 and log them.
  4. Ship the header as Content-Security-Policy-Report-Only in production. Nothing is blocked, so users see no change.
  5. Group reports by effectiveDirective and blocked URL. For each, either add the origin to the matching directive, or remove the code that loads it.
  6. When the only reports left are noise (browser extensions inject scripts and generate reports you cannot fix), switch the header name to Content-Security-Policy and keep the report endpoints.

Treat reports as untrusted input. MDN warns that violation reports "should be considered attacker-controlled data," so sanitize them before storing or showing them in a dashboard.

How do nonces and 'strict-dynamic' work?

A nonce is a random value you generate for each response and put in two places: the CSP header ('nonce-abc123') and a nonce="abc123" attribute on every <script> you trust. An injected script cannot know the value, so it will not run. 'strict-dynamic' then lets those trusted scripts load further scripts without listing every CDN.

web.dev's requirements for the nonce:

  • A cryptographically strong random value, ideally 128 bits or more.
  • Newly generated for every response, never reused or cached.
  • Base64 encoded.

The code that breaks under a nonce policy is predictable: inline event handlers such as onclick="..." in your HTML, and javascript: URLs. web.dev lists both as patterns to refactor into addEventListener calls before you enforce. A hash-based policy ('sha256-...') is the alternative for fully static pages that cannot generate a nonce per request.

How do you add a nonce-based CSP in Next.js?

Generate the nonce in proxy.ts (the file was called middleware.ts before Next.js 16), put it in the CSP header on both the request and the response, and Next.js attaches it to its own framework scripts, page bundles and inline scripts automatically. The trade-off is that every page using the nonce must render dynamically.

// proxy.ts
import { NextRequest, NextResponse } from 'next/server'

export function proxy(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
  const csp = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'nonce-${nonce}'`,
    "img-src 'self' blob: data:",
    "object-src 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "frame-ancestors 'none'",
    'upgrade-insecure-requests',
  ].join('; ')

  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-nonce', nonce)
  requestHeaders.set('Content-Security-Policy', csp)

  const response = NextResponse.next({ request: { headers: requestHeaders } })
  response.headers.set('Content-Security-Policy', csp)
  return response
}

What the Next.js docs add:

  • In development, add 'unsafe-eval' to script-src, because React uses eval for debugging information. Production does not need it.
  • Use a matcher to skip _next/static, _next/image, favicon.ico and prefetch requests, which do not need the header.
  • Read the nonce in a Server Component with (await headers()).get('x-nonce') and pass it to any <Script nonce={nonce}> you load, such as a tag manager.
  • Pages must render dynamically (call await connection() if one would otherwise be static). Static optimisation and ISR stop working for those pages, CDN caching needs extra setup, and Partial Prerendering is incompatible with a nonce-based CSP.

For the report-only phase, send the response header as Content-Security-Policy-Report-Only instead. The nonce keeps working, because Next.js's renderer reads it from either request header: it checks content-security-policy, then content-security-policy-report-only.

If the dynamic-rendering cost is too high for your marketing pages, Next.js has an experimental alternative for the App Router: experimental.sri adds integrity hashes to your scripts at build time, so a CSP without nonces can still drop 'unsafe-inline' while pages stay static.

Why use frame-ancestors instead of X-Frame-Options?

frame-ancestors is the CSP directive that controls who may embed your page, and it accepts a list of origins, which X-Frame-Options cannot. Set frame-ancestors 'none' unless someone must frame you, and keep X-Frame-Options: DENY beside it for older browsers.

MDN notes three rules that trip people up. frame-ancestors is not supported in a <meta> CSP, so it only works as a response header. It does not fall back to default-src, so leaving it out allows framing by anyone. And with nested frames, every ancestor must match the list. The security headers checklist has the rest of the headers to send beside the CSP.

What are the common CSP violations and their fixes?

Most violations in the first reports come from a handful of sources, and each maps to one directive. Fix the code where you can, and add an origin to a directive only when you trust it and need it.

Report saysUsual causeFix
script-src-elem blocked, blockedURL: inlineAn inline <script> without the nonceAdd the nonce, or move the code to a file
script-src-attr blockedAn onclick= or other inline handlerReplace with addEventListener
connect-src blockedAnalytics, error tracking or an API on another originAdd that exact origin to connect-src
img-src blocked, http:// URLAn old image URL on an HTTPS pageChange it to https://; see the mixed content guide
frame-src blockedA payment, video or scheduling embedAdd the provider's origin to frame-src
style-src-elem blockedA CSS-in-JS library injecting <style>Pass it the nonce, or keep 'unsafe-inline' in style-src for now
font-src blockedA web font from a font CDNAdd the font host to font-src, or self-host

upgrade-insecure-requests quietly fixes many http:// image and media URLs, but it is a stopgap; the mixed content guide shows how to find and fix the URLs themselves. AI-built apps tend to ship with no CSP at all, which is one of the gaps in the vibe-coded app security checklist.

Get the exact CSP findings for your site

LaunchScaler's free scan already reads your CSP: whether one is sent at all, whether script-src allows 'unsafe-inline' or 'unsafe-eval', and whether frame-ancestors or X-Frame-Options protects against framing. It runs with no account as part of 156 checks across 6 of its 7 categories. The full audit, $19 once for the domain, opens every one of those checks to its evidence (the header value as your server sent it) and the exact fix, and it adds 40 more checks. Run the free scan first, then open the full audit from your report.

Questions, answered

What people ask about this

01

What is a good Content-Security-Policy example?

  • A strong starting point is script-src 'nonce-{random}' 'strict-dynamic'; object-src 'none'; base-uri 'self'; frame-ancestors 'none', plus default-src 'self' and the image, style and connect sources your app uses. Generate a new nonce for every response.
02

What is Content-Security-Policy-Report-Only?

  • It is a header with the same syntax as Content-Security-Policy that reports violations without blocking anything. Ship it first, read the reports, fix or allow each source, then switch to the enforcing header.
03

Is 'unsafe-inline' in a CSP bad?

  • In script-src, yes: it lets any injected inline script run, and Mozilla's Observatory deducts 20 points for it. Use nonces or hashes instead. In style-src it is a smaller risk, and the Observatory does not penalise it there.
04

Can I set a CSP in a meta tag?

  • Partly. A meta http-equiv CSP works for most directives, but frame-ancestors and report-uri are not supported in a meta element, and Content-Security-Policy-Report-Only cannot be set that way at all. Use a response header.
05

Does a report-only CSP count in security scanners?

  • Not as protection. Mozilla's Observatory scores a site with only Content-Security-Policy-Report-Only the same as a site with no CSP, a 25-point penalty, because nothing is enforced.

Sources

  1. MDN: Content-Security-Policy-Report-Onlydeveloper.mozilla.org
  2. MDN: CSP report-todeveloper.mozilla.org
  3. MDN: CSP report-urideveloper.mozilla.org
  4. MDN: CSP frame-ancestorsdeveloper.mozilla.org
  5. web.dev: Mitigate cross-site scripting (XSS) with a strict Content Security Policyweb.dev
  6. MDN HTTP Observatory source: scoring chart (charts.js)github.com
  7. MDN HTTP Observatory source: CSP analyzer (csp.js)github.com
  8. Next.js: How to set a Content Security Policynextjs.org
  9. Next.js source: app-render (nonce read from the CSP request header)github.com
  10. Next.js: Proxynextjs.org

Newsletter

What launched, every Monday

Every Monday: what launched on LaunchScaler that week, what is launching next, and the reviews we published. Confirm your email once and you're in.

More from the blog

Guide

Cookie banner requirements under GDPR: no tracking before consent

A GDPR cookie banner sets nothing optional before consent, puts Reject all beside Accept all, pre-ticks nothing and eases withdrawal. How to test it.

LaunchScalerSep 28, 2026 · 9 min read
Guide

Core Web Vitals assessment failed: which metric to fix first

The assessment fails when LCP, INP or CLS misses Good at the 75th percentile of 28 days of real Chrome data. How to read it and which metric to fix first.

LaunchScalerSep 28, 2026 · 8 min read
Guide

Core Web Vitals checkers compared: what each one measures

LaunchScaler, PageSpeed Insights, Lighthouse, Search Console and CrUX Vis compared: which read real-user field data, which run lab tests, and why.

LaunchScalerSep 28, 2026 · 9 min read