Implement Content Security Policy (CSP)
Introduction
Content Security Policy (CSP) is a browser-enforced header that lists which sources are allowed to run/load on a page — scripts, styles, images, connections, frames. It doesn’t fix an XSS vulnerability, but it’s a second line of defense: even if malicious content gets injected into the page, the browser refuses to execute it if it doesn’t match the policy.
This page documents the fleet-wide rollout tracked in issue #329 (September 2026). CSP was implemented app by app, centralizing the logic in the shared auth bundle along the way.
Which mechanism should I use?
| Situation | Mechanism |
|---|---|
Your app already calls requireTokenMiddleware for auth (the vast majority of apps) | Pass a 4th argument (csp options) to requireTokenMiddleware — see below |
| Your app has no auth at all (e.g. a public form) | Call applyCspWithNonce / ensureRequestHasCspHeaders directly — see Apps without auth |
phpreaction-frontend-login-react specifically | Its own local implementation (see Special case: login-react) |
If you’re not sure which one an app is on: check src/middleware.ts for requireTokenMiddleware. If it’s there, you’re in the first case.
Standard: requireTokenMiddleware’s csp option
All CSP logic (nonce generation, header construction) lives in src/middleware/csp.ts in @phpcreation/frontend-auth-authorization-flow-react-nextjs-bundle (published on GitHub Packages, currently 2.15.3), and is wired into requireTokenMiddleware’s optional 4th argument.
// src/middleware.ts
import { requireTokenMiddleware } from "@phpcreation/frontend-auth-authorization-flow-react-nextjs-bundle/middleware";
export async function middleware(req: NextRequest) {
return requireTokenMiddleware(req, intlMiddleware, undefined, {
connectSrc: ["https://o468658.ingest.us.sentry.io"],
imgSrc: ["https://icons.phpr.link"],
});
}The CspOptions fields
type CspOptions = {
scriptSrc?: string[];
connectSrc?: string[];
imgSrc?: string[];
frameSrc?: string[];
frameAncestors?: string[];
};Only pass what the app actually uses — verified in code, never guessed. The baseline (default-src 'self', object-src 'none', base-uri 'self', a fresh nonce per request) is enforced for every app and cannot be loosened. frame-ancestors defaults to 'none' (nobody can iframe the app) unless frameAncestors is explicitly set.
Don’t add connectSrc for Sentry blindly. If the app’s next.config.js sets tunnelRoute in withSentryConfig (routes browser Sentry calls through a same-origin rewrite instead of Sentry’s domain directly), the app doesn’t need connectSrc at all — 'self' already covers it. Check before adding.
How to find what an app actually needs
NEXT_PUBLIC_SENTRY_DSNinenvironments/.env*→ the host (alwayso468658.ingest.us.sentry.iofor this org) goes inconnectSrc, unlesstunnelRouteis active (see above).images.domains/images.remotePatternsinnext.config.js→imgSrc.grep -rn "recaptcha\|iframe" src(real.ts/.tsxcode, not.mdxdocs content) →scriptSrc/frameSrc/frameAncestors, only if something real is found.- If
next.config.jsalready has aheaders()block settingContent-Security-Policy, remove it once the middleware sets it — having both sends two headers for the same routes. Leave other headers (Strict-Transport-Security,X-Frame-Options, …) untouched.
Rollout status (September 2026 audit)
| App | Extra CSP options passed |
|---|---|
connectSrc (Sentry), imgSrc (icons.phpr.link), frameAncestors (*.login.phpr.link — embedded by Login) | |
| Inventory | connectSrc (Sentry), imgSrc (icons.phpr.link, *.phpreaction.com) |
| Punch, POS, Dashboard, Report | connectSrc (Sentry), imgSrc (icons.phpr.link) |
imgSrc only — no connectSrc, tunnelRoute is active | |
| Account | connectSrc (Sentry), imgSrc (data:, icons.phpr.link, gravatar.com) |
| CRUDv2, Account CRUD, Configuration CRUD, Ticket CRUD | imgSrc only (*.phpr.link, *.phpcreation.com, flagcdn.com) — Sentry is commented out/unused in these apps |
| Feedback | see Apps without auth |
| Doc | blocked, see Known limitation |
Apps without auth (standalone functions)
ticket-client-feedback-react-nextjs (the public client feedback form) has no auth check at all, so it doesn’t call requireTokenMiddleware. applyCspWithNonce and ensureRequestHasCspHeaders are exported separately from the bundle for exactly this case:
// src/middleware.ts
import {
applyCspWithNonce,
ensureRequestHasCspHeaders,
} from "@phpcreation/frontend-auth-authorization-flow-react-nextjs-bundle/middleware";
export default function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith("/monitoring")) return;
if (!request.nextUrl.pathname.startsWith("/api")) {
const csp = applyCspWithNonce(request, {
imgSrc: ["https://icons.phpr.link"],
frameAncestors: ["*.login.phpr.link"], // Login embeds this form in an iframe
});
return ensureRequestHasCspHeaders(intlMiddleware(request), csp);
}
}requireTokenMiddleware itself is just these two functions plus the auth/locale logic — use them directly whenever an app doesn’t fit that shape.
How the nonce actually reaches the browser (and Next.js)
This is the part that looks the most “magic” in ensureRequestHasCspHeaders — worth understanding before touching csp.ts.
There are two separate header channels, and both must be filled:
- Request headers (
x-nonce, invisible to the browser) — forwarded to the Next.js app viaNextResponse.next({ request: { headers } }), the only documented way to make Next.js read a header for the current render and tag its own<script>tags with the matching nonce. - Response headers (
Content-Security-Policy, sent to the browser) — the actual rule the browser enforces.
Forgetting the request-header side means Next.js’s own scripts don’t get the nonce and get blocked by the browser (visible breakage). Forgetting the response-header side means the browser never receives the policy at all (silent, no protection).
Redirects are skipped (no HTML served, nothing to protect). Rewrites are also skipped, but for a different, less obvious reason: a next-intl rewrite sets x-middleware-rewrite, and rebuilding the response would destroy that header and break routing. The tradeoff: a rewritten route currently gets served with no CSP at all (fails open). This is low-risk today because no app in the fleet configures localePrefix in a way that triggers next-intl rewrites — revisit if that ever changes.
Special case: login-react
phpreaction-frontend-login-react does not use the shared bundle’s CSP functions — it predates them and has its own local implementation directly in src/middleware.ts (generateNonce, applyCspWithNonce, ensureRequestHasCspHeaders, hand-rolled). Functionally equivalent to the bundle’s version — it’s actually where the bundle’s implementation was modeled from — but not imported from anywhere. If you’re working on login-react, follow its existing local pattern rather than importing the shared bundle’s functions.
Known limitation: Next.js 14 vs 15
frontend-documentation-react-nextjs13 (the Doc app, this site) runs Next.js 15.2.1. The bundle declares "next": "14.2.28" as a regular dependency (not a peerDependency), so TypeScript sees two structurally different NextRequest/NextResponse types and rejects applyCspWithNonce(req, ...):
Argument of type 'NextRequest' is not assignable to parameter of type 'NextRequest'.
Type 'NextRequest' is missing the following properties from type 'NextRequest': geo, ip, [INTERNALS]Not fixed yet. The correct fix is changing next to a peerDependency with a broad range (e.g. >=14 <16) in the bundle, so each consuming app’s own Next.js version is used for type resolution instead of a pinned copy — but that’s another bundle version bump + PR. Until then, Doc is excluded from the rollout. If you pick this up, patch the bundle first.
Security checklist