Security headers and CSP
How Next Starter configures HTTP security headers and a Content Security Policy in next.config.ts, and how to add your own external domains.
How it works
The security headers live in one place, the securityHeaders array in next.config.ts, handed to Next.js through the headers() async function. The same file sets poweredByHeader: false, which drops the X-Powered-By: Next.js header from every response.
headers() returns three kinds of rule:
/:path*: applies every security header to every path, the sitemap androbots.txtincluded.noIndexRoutes: these routes get the security headers plusX-Robots-Tag: noindex, which tells search engines not to index them while still letting them follow links. The list isAPP_CONFIG.noIndexRoutesinlib/config.ts: auth, API, dashboard, onboarding, and the legal pages (/privacy,/terms)./manifest.webmanifest: adds aCache-Controlrule.
A request can match more than one of these, and every match contributes. /manifest.webmanifest matches the first rule too, so it gets the full set of security headers alongside its Cache-Control. Where two matching rules set the same header, the last one wins.
headers() runs at build or boot, never per request. The dev server watches next.config.ts and restarts itself when you save it; in production a header change needs a rebuild.
Headers reference
| Header | Value | What it does |
|---|---|---|
X-DNS-Prefetch-Control | on | Turns on DNS prefetching, so the browser can resolve hosts it finds in the page (your CDN, Turnstile) before it needs them. Most browsers do this anyway; the header makes it explicit. |
X-Content-Type-Options | nosniff | Forces the browser to trust the declared content type instead of guessing it. |
Strict-Transport-Security | max-age=63072000; includeSubDomains; preload | HSTS: once a browser has seen this over HTTPS, it refuses plain HTTP for your domain and its subdomains for 2 years (the value is in seconds). preload on its own enrols you in nothing. You submit at hstspreload.org, and getting back off that list is slow, so drop the keyword unless you mean it. |
Referrer-Policy | strict-origin-when-cross-origin | Controls how much of the current URL is sent when a user follows a link out. Full URL on same-origin requests, only the origin to anywhere else, nothing on an HTTPS-to-HTTP downgrade. |
X-Frame-Options | SAMEORIGIN | Stops other sites from loading your pages inside an iframe (clickjacking). Older mechanism; the CSP frame-ancestors below does the same job for modern browsers. |
Content-Security-Policy | see below | Lists which sources may supply each kind of content. Broken out in its own section below. |
Cross-Origin-Opener-Policy | same-origin | Cuts the link between your tab and any window that opened it from another site, so they can't poke at each other. |
Cross-Origin-Resource-Policy | cross-origin | Lets any origin embed a resource your app serves. This is the permissive end of the header. same-origin would stop other sites pulling in your assets. |
Permissions-Policy | camera=(), microphone=(), … | Turns off browser features the app doesn't use, so a stray script can't reach them (see below). |
Content Security Policy
In code the CSP is an array of directives joined with "; ", one rule per resource type. The defaults cover what the starter ships with: Cloudflare Turnstile (see Turnstile), R2 for file uploads, and Google profile images from OAuth accounts.
| Directive | Value | Why |
|---|---|---|
default-src | 'self' | The fallback for fetch directives. Any content type without its own rule below may load only from your own origin. |
object-src | 'none' | Blocks old plugin content like Flash and Java applets. |
base-uri | 'self' | Confines an injected <base> tag to your own origin, so it can't point relative links and scripts at somebody else's server. |
form-action | 'self' | Forms may only submit back to your own origin. |
img-src | 'self' data: blob: https://*.googleusercontent.com https://<cdn> | Images from your origin, inline data:/blob: images, Google avatars, and your CDN host. |
script-src | 'self' 'unsafe-inline' https://challenges.cloudflare.com | Scripts from your origin, inline scripts, and Turnstile. In development only, 'unsafe-eval' is also added. |
style-src | 'self' 'unsafe-inline' https://fonts.googleapis.com | Styles from your origin, inline styles, and Google Fonts stylesheets. |
font-src | 'self' https://fonts.gstatic.com | Font files from your origin and Google Fonts. |
connect-src | 'self' https://*.r2.cloudflarestorage.com https://challenges.cloudflare.com | Where fetch()/XHR calls from your pages may go: your origin and R2 direct uploads. The Turnstile entry is spare (see below). |
frame-ancestors | 'self' | Which pages may frame yours, only ones on your own origin. This is the modern version of X-Frame-Options. |
frame-src | 'self' https://challenges.cloudflare.com | Which sites you may embed in an iframe. Allows the Turnstile widget. |
upgrade-insecure-requests | (no value) | Rewrites any http:// sub-resource to https:// before fetching it. Present in every environment, development included. |
The config settles two things when it loads. <cdn> is your CDN host, taken from NEXT_PUBLIC_STORAGE_S3_CDN_URL with new URL(...).host; next.config.ts reads process.env directly rather than the validated env object, so that host has to be set at build time. And 'unsafe-eval' joins script-src only when isDev is true, because React uses eval in development for richer debugging information. Neither React nor Next.js uses it in production by default.
'unsafe-inline' permits inline <script>, inline <style>, and inline event handlers, and it ships in production because Next.js and the UI both need it. With 'unsafe-inline' in script-src and no nonce, the policy won't stop an injected inline script. So this CSP is one layer among several, not an XSS barrier on its own. Dropping 'unsafe-inline' means generating a per-request nonce in proxy.ts and threading it through, which Next.js says requires dynamic rendering. You trade your static pages for it. Their CSP guide has the pattern.
A couple of entries are along for the ride. The Google Fonts hosts in style-src and font-src go unused, because the app loads Inter and Pacifico through next/font/google, which fetches them at build time and serves them from your own origin. Cloudflare asks for challenges.cloudflare.com in script-src and frame-src only, so the connect-src entry is spare: the widget's own requests come from its iframe, a separate document that answers to its own policy. Neither does any harm. Take them out if you like a tidy policy, or keep them for the day you link a stylesheet-hosted font.
What you can change
The headers and the CSP are all in next.config.ts. The no-index route list is the one piece kept elsewhere, in lib/config.ts.
Add an external domain to a directive. Find the directive in the CSP array and append the origin. For example, to load an analytics script and call its API:
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://challenges.cloudflare.com https://cdn.example.com`,
"connect-src 'self' https://*.r2.cloudflarestorage.com https://challenges.cloudflare.com https://api.example.com",A web font needs font-src for the font file, and style-src too if the @font-face rule arrives from another host. That second edit is the one people miss.
A new image host may need two edits. The same file lists images.remotePatterns, currently *.googleusercontent.com plus your CDN host. Which setting you need depends on how the image is rendered. next/image refuses to optimize a remote host missing from remotePatterns, but the browser then loads the optimized copy from your own origin, so img-src never sees that host. A plain <img> or an <Image unoptimized> fetches the remote host directly and does need it in img-src. The starter leans on the second path: components/users/user-avatar.tsx renders avatar URLs through Radix's AvatarImage, a plain <img> underneath, and the admin file previews use <Image unoptimized>. remotePatterns is there for optimized remote images you add later.
Server Actions and your domain. One more security setting sits in the same file, though it isn't a header. experimental.serverActions.allowedOrigins is set to the host of APP_CONFIG.production.baseUrl plus its www. variant. Next.js compares a Server Action's Origin header against the forwarded host, and when the two differ, as they can behind a reverse proxy, it rejects the action unless the origin is on this list. So when you change domains, update production.baseUrl in lib/config.ts too, or that safety net points at the wrong host.
Adjust Permissions-Policy. The full value is camera=(), microphone=(), geolocation=(), browsing-topics=(), accelerometer=(), gyroscope=(), magnetometer=(), payment=(), usb=(). Each feature=() denies that feature to everyone, your own pages included. Swap the empty list for (self) to allow one: camera=(self) lets your pages ask for the camera, and the user still has to grant permission.
Verify
curl -I https://yourdomain.comCheck the response for Content-Security-Policy, Strict-Transport-Security, and the rest. securityheaders.com grades a deployed domain. Locally the dev server restarts on save, but a deployed change needs a rebuild before you can test it.
Environment Variables
How Next Starter validates environment variables with Zod at startup, plus a grouped config reference for every required and optional variable.
Cloudflare Turnstile
Next Starter puts a Cloudflare Turnstile check (MANAGED mode) on the contact form, verified server-side and invisible for most visitors.