AI Web FeedsAI Web FeedsOpen web AI reader
Documentation

Strict Nonce-Based CSP

Per-request nonce Content-Security-Policy enforcement via middleware for safe inline JsonLd scripts without 'unsafe-inline' on script-src. Production enforcement (Path B) for Next.js 15 App Router + Fumadocs.

Source: apps/web/content/docs/development/security-csp-nonce.mdx

Motivation and Decision

We chose Path B: enforce a strict nonce-based Content-Security-Policy (CSP) in production immediately, rather than starting in Report-Only mode.

Why middleware is the only place per-request nonces can be generated:

  • In Next.js 15 App Router, headers() is a dynamic function. It opts the route into dynamic rendering and can only be read at request time.
  • next.config.* headers() (and async headers()) are evaluated at build time / for static routes. They cannot produce a unique nonce per response.
  • Middleware runs on every matching request and is the canonical location for setting per-request response headers (including Content-Security-Policy with a fresh nonce and the x-nonce header that Server Components read via headers()).

The implementation centralizes nonce generation in middleware, exposes it via a tiny DRY helper (getRequestNonce), and forwards it only to the <JsonLd> components that render our controlled inline <script type="application/ld+json"> tags.

The Policy (Exact Directives + Rationale)

The policy is generated fresh on every request in middleware.ts:

const nonce = btoa(crypto.randomUUID());

const csp = ["default-src 'self'", `script-src 'self' 'nonce-${nonce}'`, "style-src 'self' 'unsafe-inline'", "img-src 'self' data: https:", "font-src 'self' data: https://fonts.gstatic.com", "connect-src 'self' https: wss:", "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'"].join("; ");

Directive-by-Directive Rationale

DirectiveValueRationale
default-src'self'Baseline: only same-origin resources by default.
script-src'self' 'nonce-${nonce}'Strict. No 'unsafe-inline', no 'unsafe-eval'. Only our per-request nonced inline JsonLd scripts + first-party bundles. This is the core security win.
style-src'self' 'unsafe-inline'Pragmatic documented exception. Tailwind 4, Fumadocs UI, shadcn/ui, katex, and tw-animate-css emit style rules that are not practical to nonce at runtime today. We accept the (limited) risk and document it.
img-src'self' data: https:Remote feed images come from arbitrary external sources. https: is required for the reader use case.
font-src'self' data: https://fonts.gstatic.comSelf-hosted + Google Fonts (common for Fumadocs + design system).
connect-src'self' https: wss:Same-origin APIs, Better Auth OAuth flows, remote feed fetches in the reader, future WebSocket real-time features. Broad for now; can be tightened with a report-to endpoint later.
frame-ancestors'none'Clickjacking defense (equivalent to X-Frame-Options: DENY).
base-uri'self'Prevents base-tag injection attacks.
form-action'self'Forms can only submit to same origin.

Static security headers (also set in middleware so they apply to redirects/rewrites, and duplicated in next.config.mjs for static completeness):

  • X-Content-Type-Options: nosniff
  • X-Frame-Options: DENY
  • Referrer-Policy: strict-origin-when-cross-origin

Developer Usage

In any Server Component or page that renders structured data via <JsonLd> (or future nonced inline scripts):

import { getRequestNonce } from "@/lib/nonce";
import { JsonLd } from "@/components/json-ld";
import { organizationJsonLd, websiteJsonLd } from "@/lib/structured-data";

export default async function Layout({ children }: { children: React.ReactNode }) {
  const nonce = await getRequestNonce();

  return (
    <html lang="en">
      <body>
        <JsonLd data={[organizationJsonLd(), websiteJsonLd()]} nonce={nonce} />
        {children}
      </body>
    </html>
  );
}

The small helper lib/nonce.ts was extracted for DRYness:

import { headers } from "next/headers";

export async function getRequestNonce(): Promise<string | undefined> {
  return (await headers()).get("x-nonce") ?? undefined;
}

It gracefully returns undefined for static renders or when middleware did not run (e.g., certain dev or test paths).

The JsonLd Component

components/json-ld.tsx accepts an optional nonce prop and sets it on the script element:

export function JsonLd({ data, nonce }: JsonLdProps) {
  return (
    <script
      type="application/ld+json"
      nonce={nonce}
      dangerouslySetInnerHTML={{
        __html: JSON.stringify(data).replace(/</g, "\\u003c"),
      }}
    />
  );
}

This is the only inline <script> we control and intentionally allow through the strict script-src policy.

Verification & Observability

Run these checks after any change to middleware, the nonce helper, or JsonLd usage:

  1. Headers on every response (different nonce each time):

    curl -I http://localhost:3000
    # Look for:
    # x-nonce: <base64-uuid>
    # content-security-policy: default-src 'self'; script-src 'self' 'nonce-...'; ...
  2. Per-request uniqueness:

    for i in 1 2 3; do curl -sI http://localhost:3000 | grep -i '^x-nonce:'; done
  3. View source / DevTools on key surfaces:

    • / (home)
    • /docs (and any docs page)
    • /reader, article pages, source pages, topic pages, dashboard
    • Confirm <script type="application/ld+json" nonce="..."> attributes are present on the JsonLd blocks.
    • Confirm no CSP violations in the browser console for these scripts.
  4. Production build succeeds with middleware:

    pnpm --dir apps/web build

    The standalone output must include the middleware (verified by successful build + runtime header behavior).

Surfaces Covered and Policy Shape

The policy is deliberately shaped for the actual runtime surfaces of this app:

  • JsonLd (only controlled inline script): organization, website, collectionPage, article, dataFeed, breadcrumbs — all go through the nonced component.
  • Reader surfaces: same-origin API calls + remote feed images.
  • DOMPurify sanitizer (used for feed content): runs in a way compatible with the policy.
  • Fumadocs + MDX: Mermaid diagrams, Katex math, UI components.
  • Better Auth flows: OAuth redirects/callbacks (covered by connect-src).
  • Viz libraries: Chart.js, Three.js / React Three Fiber (self-hosted bundles + any data URLs).
  • Remote feed images: arbitrary https: origins (reader core feature).

connect-src and img-src are intentionally broad because this is a feed reader. They can be tightened later once real-time features and exact third-party origins are finalized (ideally with a report-to / report-uri directive + monitoring).

  • Policy + generation: apps/web/middleware.ts (the canonical location; includes extensive comments)
  • DRY helper: apps/web/lib/nonce.ts
  • Component: apps/web/components/json-ld.tsx
  • Root usage: apps/web/app/layout.tsx
  • Page usage examples: app/(home)/page.tsx, app/(home)/reader/page.tsx, app/(home)/articles/[articleId]/page.tsx, app/(home)/sources/*, app/(home)/topics/*, app/(home)/dashboard/page.tsx, app/docs/[[...slug]]/page.tsx
  • Static header delegation comment: apps/web/next.config.mjs

Future Notes / Gotchas

  • Broad directives are intentional for the feed-reader use case. Do not over-tighten img-src or connect-src without reader impact assessment.
  • Report-to / CSP reporting: Adding a report-to endpoint plus report-uri for older user agents would give visibility into any future violations without breaking the current enforcement.
  • strict-dynamic: Could be added to script-src later ('strict-dynamic') if we introduce dynamic script loading from a nonced context. Current policy is already strict enough for our controlled inline scripts.
  • Style-src exception: Revisit if a future Tailwind / UI stack allows practical nonceing or hashed styles. For now it is the documented pragmatic trade-off.
  • Middleware matcher: Currently excludes api, _next/static, _next/image, favicon.ico, llms*. Changes here affect which responses receive the nonce + CSP.
This is a living security control. Any new inline script, third-party connect, or image source must be evaluated against the policy and either nonced (preferred) or explicitly added with justification.
Strict Nonce-Based CSP | AI Web Feeds