Security headers are HTTP response headers that tell browsers how to behave with your site's content. Misconfiguring them (or omitting them) leaves your users exposed to XSS attacks, clickjacking, MIME type sniffing attacks, and information leakage. Most Next.js tutorials skip security headers entirely. After working on production ERP and SaaS applications where security requirements were explicit, I've developed a standard next.config.ts security header configuration that I apply to every project — it takes 20 minutes to set up and eliminates a category of vulnerabilities.
The essential security headers for a Next.js application are: Content-Security-Policy (restricts which resources can load), Strict-Transport-Security (enforces HTTPS), X-Frame-Options (prevents clickjacking via iframes), X-Content-Type-Options (prevents MIME sniffing), Referrer-Policy (controls what referrer information is sent), and Permissions-Policy (restricts browser APIs like camera, microphone, geolocation). Each header addresses a specific attack vector. Together, they implement defense-in-depth — multiple overlapping controls that require an attacker to bypass several layers.
CSP is the most impactful security header and the most complex to configure correctly. It defines a whitelist of trusted sources for each resource type: scripts, styles, images, fonts, frames, and API connections. A strict CSP blocks inline JavaScript execution — the primary vector for XSS attacks where injected malicious script runs in your users' browsers. The challenge: most web applications use some inline scripts (from analytics, cookie consent, hydration). The solution is a nonce-based CSP that allows specific inline scripts with a cryptographic token.
Strict-Transport-Security (HSTS) tells browsers to never connect to your site over HTTP, even if the user types 'http://' or a link uses an HTTP URL. The `max-age` value is in seconds — 63072000 is two years. The `includeSubDomains` directive extends the policy to all subdomains. `preload` submits your domain to the browser's hardcoded HSTS preload list — browsers enforce HTTPS even on the first visit, before they've seen your HSTS header. Only add `preload` if you're certain all your subdomains support HTTPS.
Security Header Stack — Attack Vectors Covered:
────────────────────────────────────────────────────────────
Content-Security-Policy → XSS (inline script injection)
Strict-Transport-Security → MITM (downgrade to HTTP)
X-Frame-Options → Clickjacking (iframe overlay)
X-Content-Type-Options → MIME sniffing attacks
Referrer-Policy → Information leakage
Permissions-Policy → Browser API abuse (camera, mic)
CSP Directive Examples:
default-src 'self' → only load from own domain
script-src 'self' 'nonce-{token}' → only inline scripts with nonce
style-src 'self' 'unsafe-inline' → styles (often needed for Tailwind)
img-src 'self' data: https: → images from self + any HTTPS
connect-src 'self' https://api.example.com → API calls
frame-ancestors 'none' → no iframes anywhere
SecurityHeaders.com grades:
A+: All headers configured correctly
A: Minor issues (e.g., CSP report-only mode)
B: Missing some headers
F: No security headers configuredFrom my experience configuring CSP on Next.js apps: the biggest pain point is the nonce requirement. A nonce is a random value generated per-request and injected into both the CSP header and trusted inline script tags. Next.js's static rendering (ISR, SSG) can't generate a fresh nonce per request because the HTML is pre-rendered. The cleanest solution: use a Vercel Edge Middleware that generates the nonce, modifies the CSP header, and passes the nonce to the page via a request header. The page reads the nonce from the header and adds it to inline scripts. This keeps strict CSP even on ISR pages.
I apply security headers globally in next.config.ts using the headers() function. All routes get the full header stack. Specific routes (the Swagger UI in non-prod, for example) get relaxed headers via additional header entries that override the global config. The CSP starts in report-only mode (`Content-Security-Policy-Report-Only`) with a report URI so I can see violations before enforcing. After reviewing the violation report for a week, I switch to enforcement mode.
Permissions-Policy (formerly Feature-Policy) controls which browser APIs your site can use. Even if you don't use the camera or microphone, blocking them in the Permissions-Policy header means a XSS script injected into your site also can't access them. I set: `camera=(), microphone=(), geolocation=(), payment=()` to disable all sensitive APIs, then selectively enable what the app actually needs. If you use Stripe Elements, add `payment=(self https://js.stripe.com)` to allow Stripe's payment request API.
// next.config.ts — security headers I ship on every project
const securityHeaders = [
{
key: 'X-DNS-Prefetch-Control',
value: 'on',
},
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=(), payment=()',
},
// Start with report-only, switch to enforce after monitoring
{
key: 'Content-Security-Policy-Report-Only',
value: [
"default-src 'self'",
"script-src 'self' 'nonce-{nonce}'", // nonce injected by middleware
"style-src 'self' 'unsafe-inline'", // Tailwind requires this
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self' https://vitals.vercel-insights.com",
"frame-ancestors 'none'",
"report-uri /api/csp-report",
].join('; '),
},
]
const nextConfig: NextConfig = {
async headers() {
return [
{
source: '/(.*)', // apply to all routes
headers: securityHeaders,
},
]
},
}
// middleware.ts — nonce-based CSP for ISR/SSG pages
import { NextResponse } from 'next/server'
import crypto from 'crypto'
export function middleware() {
const nonce = crypto.randomBytes(16).toString('base64')
const response = NextResponse.next()
response.headers.set(
'Content-Security-Policy',
`script-src 'self' 'nonce-${nonce}'; ...`
)
response.headers.set('x-nonce', nonce) // page reads this for script tags
return response
}X-Frame-Options prevents your site from being embedded in an iframe on another domain (clickjacking protection). The modern equivalent is the CSP `frame-ancestors` directive, which supersedes X-Frame-Options in browsers that support CSP. I set both for maximum compatibility: `X-Frame-Options: DENY` and `Content-Security-Policy: frame-ancestors 'none'` (or `'self'` if you need to embed in your own iframes). If you use a third-party payment or identity provider that loads your pages in iframes (rare, but it happens), you'll need to allow their domain in frame-ancestors.
Deploying an enforcing CSP without testing it first will break your application. A strict CSP that blocks inline scripts will prevent hydration (if Next.js inlines scripts), block analytics, and break third-party widgets. Always start with Content-Security-Policy-Report-Only and a report-uri pointing to a CSP violation reporter (you can use report-uri.com for free). Monitor violations for a week in production — every violation is something your CSP would have blocked. Fix violations by adjusting your policy or fixing the code, then switch to enforcement mode.
SecurityHeaders.com (by Scott Helme) grades your security headers from A+ to F. Run it against your site before and after configuring headers to measure progress. For local testing, the browser DevTools Network tab shows all response headers — click any request and check the Response Headers section. In CI, add a Playwright test that fetches your homepage and asserts the presence and value of each security header. Lighthouse's 'Best Practices' audit also checks for some security headers.
If your CSP allows external script sources (CDNs, analytics), add Subresource Integrity (SRI) hashes. SRI lets you specify the cryptographic hash of a script — the browser verifies the downloaded script matches the hash before executing it. If a CDN is compromised and serves a modified script, the hash won't match and the browser blocks execution. In Next.js, the Script component supports the `integrity` prop for SRI. For scripts you load directly in HTML (via next/headers or a custom document), add the `integrity` attribute. Most CDN providers publish SRI hashes alongside their script URLs.