Next Starter Logo
Security

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 and robots.txt included.
  • noIndexRoutes: these routes get the security headers plus X-Robots-Tag: noindex, which tells search engines not to index them while still letting them follow links. The list is APP_CONFIG.noIndexRoutes in lib/config.ts: auth, API, dashboard, onboarding, and the legal pages (/privacy, /terms).
  • /manifest.webmanifest: adds a Cache-Control rule.

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

HeaderValueWhat it does
X-DNS-Prefetch-ControlonTurns 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-OptionsnosniffForces the browser to trust the declared content type instead of guessing it.
Strict-Transport-Securitymax-age=63072000; includeSubDomains; preloadHSTS: 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-Policystrict-origin-when-cross-originControls 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-OptionsSAMEORIGINStops 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-Policysee belowLists which sources may supply each kind of content. Broken out in its own section below.
Cross-Origin-Opener-Policysame-originCuts 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-Policycross-originLets 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-Policycamera=(), 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.

DirectiveValueWhy
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.comScripts from your origin, inline scripts, and Turnstile. In development only, 'unsafe-eval' is also added.
style-src'self' 'unsafe-inline' https://fonts.googleapis.comStyles from your origin, inline styles, and Google Fonts stylesheets.
font-src'self' https://fonts.gstatic.comFont files from your origin and Google Fonts.
connect-src'self' https://*.r2.cloudflarestorage.com https://challenges.cloudflare.comWhere 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.comWhich 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:

next.config.ts
`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.com

Check 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.

On this page