All posts

Nginx or Next.js: where do your security headers belong?

A practical guide to where security headers fit in a Next.js production stack - what you let Cloudflare handle, what nginx does, and what Next.js owns, based on a recent migration.

4 min read
securitynextjsnginxheaders

Recently I moved security headers on Salonnare and vaniersel.dev from nginx to Next.js. Three layers - Cloudflare at the edge, nginx as reverse proxy, Next.js as the application - give you three places where you can set those headers. The wrong answer is "all three": that yields duplicate headers, conflicting values and headache when something needs adjusting.

Here's how I have the split now, and why.

Why move to Next.js?

In the old setup all security headers lived in nginx-saas.conf: X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, Strict-Transport-Security. Worked fine, but three downsides:

  1. Per-route differentiation was painful. For /api/* and /app/* you want different Permissions-Policy values than for marketing pages. Possible in nginx with separate location blocks, but you duplicate config.
  2. Changes require nginx reload. docker compose exec nginx nginx -s reload. Not broken, but friction. In Next.js a header change is a rebuild + restart, which already happens in our CI.
  3. Headers weren't visible to the developer writing the feature. A frontend dev couldn't see that her route received a particular policy through nginx.

The split now

LayerResponsibility
CloudflareHSTS (max-age=31536000; includeSubDomains; preload), TLS termination, basic DDoS, bot scoring
NginxRouting (subdomain → container), redirects (www → apex, http → https), upstream health
Next.jsAll app-level security headers per-route via next.config.ts or route handlers

Rule of thumb: the closer to the application, the more specific the policy. HSTS is a global commitment so it belongs at the edge. Permissions-Policy for camera access in a booking widget is route-specific so it belongs in Next.js.

What I put in Next.js

// next.config.ts
const securityHeaders = [
  { key: 'X-Content-Type-Options', value: 'nosniff' },
  { key: 'X-Frame-Options', value: 'SAMEORIGIN' },
  { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
  { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(self)' },
];

export default {
  async headers() {
    return [{ source: '/(.*)', headers: securityHeaders }];
  },
};

I deliberately did not add X-XSS-Protection - that header is deprecated in modern browsers (Chrome, Firefox) and can even open new XSS vectors on older versions. CSP (Content-Security-Policy) is intentionally deferred: a good CSP requires an audit of all scripts, fonts and inline styles, and that's a sprint of its own.

HSTS preload list submission

Strict-Transport-Security with preload tells browsers: "never trust an insecure connection to this domain". You then submit your domain at hstspreload.org and it gets included in the Chrome HSTS Preload List, which is also used by Firefox, Safari and Edge.

For vaniersel.dev this was approved recently. For salonnare.com the request is in the queue (typically 2-8 weeks). The requirements are non-trivial:

  • Valid HTTPS certificate (all subdomains)
  • HTTP redirect to HTTPS on all hosts
  • HSTS header with max-age=31536000 minimum, includeSubDomains, preload
  • Apex and www subdomain supported

Important: once you accept preload, you can't get out quickly. Removal from the preload list takes months and all that time browsers refuse HTTP connections to your domain. Only do this if you're 100% certain you'll never need HTTP again.

Nginx for what it actually does well

My nginx config now consists of:

  • TLS termination (already done by Cloudflare, double layer for mTLS to containers)
  • Subdomain → container routing (*.vaniersel.dev → server proxy)
  • 301/308 redirects (www.vaniersel.devvaniersel.dev)
  • Static asset caching headers (Cache-Control for /_next/static/*)
  • Health checks to upstream

No security headers. One line proxy_set_header X-Forwarded-Proto $scheme; to let Next.js know the original request was HTTPS, that's it.

A redirect trap: 307 vs 308

During the same migration I discovered our locale redirect middleware (//nl) was using HTTP 307 (temporary) instead of 308 (permanent). To browsers it doesn't matter - both redirect correctly. To Google it does: 307 gets read as "this page may return on the requested URL" and creates canonical confusion in Search Console.

// next.config.ts
async redirects() {
  return [{
    source: '/',
    destination: '/nl',
    permanent: true, // 308, not 307
  }];
}

For middleware-driven locale redirects I now use NextResponse.redirect(url, 308) explicitly.

Verify your headers from outside

After every change:

curl -I https://vaniersel.dev | grep -E '(strict|frame|content-type|referrer|permissions)'

Or use securityheaders.com for an extensive grade. My target is A+ - achievable without CSP at B+ to A, with CSP easier.

What I recommend

  • Set HSTS once at the edge (Cloudflare or nginx), not in your app.
  • Put the rest in Next.js, close to the developer building the feature.
  • Only do the preload list submission once your domain is definitively HTTPS-only and you're 100% convinced of that.
  • Avoid duplicate headers. An X-Frame-Options on two layers isn't "double safe" - it's a hint that one of the two will disappear without anyone noticing.
  • Test headers from outside your network, not just locally - Cloudflare sometimes adds or strips headers.

Header migrations are small work in code, but big work in mental model. The split above works for two production sites and gives me a grade A on securityheaders.com without me having to touch nginx every month.

Nick van Iersel

Author of this site

Nick van Iersel

Full-stack Developer & IT Consultant

Nick is a Dutch full-stack developer and IT consultant based in Waalwijk with over six years of production software experience. He focuses on Next.js, TypeScript, React and Node.js for websites, SaaS platforms and mobile apps, and works both hourly and on fixed-price projects.

Ready to make your idea a reality?

Let's build something amazing together