Nginx of Next.js: waar horen je security headers?
Een praktische gids voor waar je security headers thuishoren in een Next.js productiestack - wat je laat doen door Cloudflare, wat door nginx en wat door Next.js zelf, op basis van een recente migratie.
Recent heb ik op Salonnare en vaniersel.dev de security headers verschoven van nginx naar Next.js. Drie lagen - Cloudflare aan de edge, nginx als reverse proxy, Next.js als applicatie - geven je drie plekken waar je die headers kunt zetten. Het verkeerde antwoord is "alle drie": dat geeft duplicate headers, conflicterende waarden en een hoofdpijn als je iets moet aanpassen.
Dit is hoe ik de verdeling nu heb staan, en waarom.
Waarom verschuiven naar Next.js?
In de oude opzet zaten alle security headers in nginx-saas.conf: X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, Strict-Transport-Security. Werkte prima, maar drie nadelen:
- Per-route differentiatie was lastig. Voor
/api/*en/app/*wil je anderePermissions-Policywaarden dan voor de marketing pagina's. In nginx kun je dat met aparte location blocks, maar dan duplicate je config. - Wijzigingen vereisen nginx reload.
docker compose exec nginx nginx -s reload. Niet kapot, wel friction. In Next.js is een header wijziging een rebuild + restart, wat in onze CI al gebeurt. - Headers waren niet zichtbaar voor de developer die de feature schreef. Een frontend dev kon niet zien dat zijn route door nginx een bepaalde policy kreeg.
De verdeling nu
| Laag | Verantwoordelijkheid |
|---|---|
| Cloudflare | HSTS (max-age=31536000; includeSubDomains; preload), TLS termination, basic DDoS, bot scoring |
| Nginx | Routing (subdomain → container), redirects (www → apex, http → https), upstream health |
| Next.js | Alle app-level security headers per-route via next.config.ts of route handlers |
De vuistregel: hoe dichter bij de applicatie, hoe specifieker de policy. HSTS is een wereldwijde commitment dus die hoort op de edge. Permissions-Policy voor camera-toegang in een booking widget is route-specifiek dus die hoort in Next.js.
Wat ik in Next.js heb gezet
// 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 }];
},
};
X-XSS-Protection heb ik niet meer toegevoegd - die header is in moderne browsers (Chrome, Firefox) afgeschaft en kan zelfs nieuwe XSS-vectoren openen op oudere versies. CSP (Content-Security-Policy) is bewust uitgesteld: een goede CSP vereist een audit van alle scripts, fonts en inline styles, en dat is een aparte sprint waard.
HSTS preload list submission
Strict-Transport-Security met preload zegt browsers: "vertrouw nooit een onbeveiligde verbinding naar dit domein". Vervolgens dien je je domein in op hstspreload.org en wordt het opgenomen in de Chrome HSTS Preload List, die ook door Firefox, Safari en Edge wordt gebruikt.
Voor vaniersel.dev is dit recent goedgekeurd. Voor salonnare.com staat de aanvraag in de queue (gemiddeld 2-8 weken). De vereisten zijn niet triviaal:
- Geldig HTTPS certificaat (alle subdomeinen)
- HTTP redirect naar HTTPS op alle hosts
- HSTS header met
max-age=31536000minimum,includeSubDomains,preload - Apex én www subdomain ondersteund
Belangrijk: als je preload eenmaal accepteert, kan je er niet snel uit. Verwijdering uit de preload list duurt maanden en al die tijd weigeren browsers HTTP-verbindingen naar je domein. Doe dit alleen als je 100% zeker bent dat je nooit meer naar HTTP terug hoeft.
Nginx alleen voor wat het écht goed doet
Mijn nginx config bestaat nu uit:
- TLS termination (al gedaan door Cloudflare, dubbele laag voor mTLS naar containers)
- Subdomain → container routing (
*.vaniersel.dev→ server proxy) - 301/308 redirects (
www.vaniersel.dev→vaniersel.dev) - Static asset caching headers (
Cache-Controlvoor/_next/static/*) - Health checks naar upstream
Geen security headers. Eén regel proxy_set_header X-Forwarded-Proto $scheme; om Next.js te laten weten dat de origineel-request HTTPS was, dat is het.
Een redirect val: 307 vs 308
Tijdens dezelfde migratie ontdekte ik dat onze locale redirect middleware (/ → /nl) HTTP 307 (temporary) gebruikte in plaats van 308 (permanent). Voor browsers maakt het niet uit - beide redirecten correct. Voor Google maakt het wel uit: 307 wordt gezien als "deze pagina kan op de gevraagde URL terugkomen" en zorgt voor canonical onduidelijkheid in Search Console.
// next.config.ts
async redirects() {
return [{
source: '/',
destination: '/nl',
permanent: true, // 308, niet 307
}];
}
Voor middleware-gestuurde locale redirects gebruik ik NextResponse.redirect(url, 308) expliciet.
Verifieer je headers vanaf buiten
Na elke wijziging:
curl -I https://vaniersel.dev | grep -E '(strict|frame|content-type|referrer|permissions)'
Of gebruik securityheaders.com voor een uitgebreide rapportage met grade. Mijn doelstelling is A+ - haalbaar zonder CSP (B+ tot A), met CSP makkelijker.
Wat ik adviseer
- Zet HSTS één keer op de edge (Cloudflare of nginx), niet in je app.
- Zet de rest in Next.js, dichtbij de developer die de feature bouwt.
- Doe de preload list submission pas als je domein definitief HTTPS-only is en je daar zelf 100% van overtuigd bent.
- Vermijd duplicate headers. Een
X-Frame-Optionsop twee lagen is niet "dubbel veilig" - het is een aanwijzing dat één van de twee verdwijnt zonder dat iemand het merkt. - Test je headers vanaf buiten je netwerk, niet alleen lokaal - Cloudflare voegt of strip soms headers toe.
Header migraties zijn klein werk in code, maar groot werk in mentale model. De verdeling boven werkt voor twee productiesites en geeft me een grade A op securityheaders.com zonder dat ik elke maand nginx hoef aan te raken.
