Next.js Security Headers on Vercel: Fixing a 25/100 Score
I ran my portfolio through a security-header scanner and got an E: 25/100. The only thing it found was Strict-Transport-Security, and that wasn't even mine — Vercel adds it to every deployment. Everything else was missing:
Content-Security-PolicyX-Frame-OptionsX-Content-Type-OptionsReferrer-PolicyPermissions-Policy
None of these change how the site looks. They tell the browser what it's not allowed to do with your pages, so a bug or an injected script has much less room to cause damage. Here's what I shipped and why.
Where the headers live
In Next.js the simplest place is headers() in next.config.mjs. It applies to every route, including static pages, which middleware can't do without forcing them dynamic.
const securityHeaders = [
{ key: 'Content-Security-Policy', value: csp },
{ 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=(), usb=(), browsing-topics=()' },
{ key: 'Cross-Origin-Opener-Policy', value: 'same-origin' },
{ key: 'Cross-Origin-Resource-Policy', value: 'same-origin' },
{ key: 'X-Permitted-Cross-Domain-Policies', value: 'none' },
]
const nextConfig = {
poweredByHeader: false,
async headers() {
return [{ source: '/:path*', headers: securityHeaders }]
},
}
poweredByHeader: false drops the X-Powered-By: Next.js header. It's not a real defence, but there's no reason to advertise your stack either.
The Content Security Policy
CSP is the one that matters most and the one that's easiest to break your site with. The goal is to list every origin the browser may load things from. For a site like mine that's short: almost everything is same-origin, plus Vercel Analytics and Vercel's preview toolbar.
const dev = process.env.NODE_ENV !== 'production'
const csp = [
"default-src 'self'",
`script-src 'self' 'unsafe-inline'${dev ? " 'unsafe-eval'" : ''} https://va.vercel-scripts.com https://vercel.live`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"font-src 'self' data: https://vercel.live",
`connect-src 'self' https://vitals.vercel-insights.com https://vercel.live wss://ws-us3.pusher.com${dev ? ' ws:' : ''}`,
'frame-src https://vercel.live',
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
...(dev ? [] : ['upgrade-insecure-requests']),
].join('; ')
A few of these lines are worth explaining.
Why 'unsafe-inline' for scripts
The "strict" way to do CSP in Next.js is a nonce: middleware generates a random value per request and Next.js stamps it on its inline scripts. The catch is that a nonce has to be different on every request, so every page becomes dynamically rendered. My site is fully static (SSG) with on-demand revalidation, and giving that up to tighten one directive would cost real performance.
Next.js inlines its own hydration data, and I have an inline script that runs before paint and a few JSON-LD blocks. So script-src allows inline scripts but only from my origin — no *, no CDN wildcards, no 'unsafe-eval' in production. Even this "relaxed" CSP still does a lot:
object-src 'none'kills plugin-based attacks.base-uri 'self'stops an injected<base>tag from redirecting every relative URL.form-action 'self'means a form can only submit to my own server.frame-ancestors 'none'stops anyone from framing the site (clickjacking).
If your site is already dynamic, use the nonce approach — the Next.js docs have a complete middleware example.
Dev vs production
React's development build uses eval for better stack traces, and the dev server uses a WebSocket for hot reload. So 'unsafe-eval' and ws: are added only when NODE_ENV isn't production. upgrade-insecure-requests is production-only for the opposite reason: localhost runs over plain HTTP.
img-src https:
Blog posts are markdown and could include an image from anywhere, so images are allowed from any HTTPS origin. Images can't run code, so this is a reasonable trade-off. Scripts and connections stay locked down.
The other headers, one line each
X-Frame-Options: DENY: the older version offrame-ancestors 'none'. Modern browsers use CSP, but scanners and older browsers still look for it.X-Content-Type-Options: nosniff: the browser trusts theContent-TypeI send instead of guessing. A file served as text can't be "sniffed" into a script.Referrer-Policy: strict-origin-when-cross-origin: other sites see onlyhttps://iamsrikanth.comin the referrer, never the full path, and nothing at all over HTTP.Permissions-Policy: the site never needs the camera, microphone, location, payments, USB or the Topics API, so it switches them all off, including for any embedded content.Cross-Origin-Opener-Policy: same-origin: pages opened from my site can't keep a handle on my window, and vice versa.
The CORP exception for share images
Cross-Origin-Resource-Policy: same-origin tells browsers that other websites can't embed my files. That's what I want for pages and scripts. It's not what I want for Open Graph images, icons or my résumé PDF, which other sites and apps are supposed to display. Next.js applies matching header rules in order, so a second rule overrides the first for just those paths:
{
source: '/:path((?:.*/)?(?:opengraph-image|twitter-image|manifest\\.webmanifest|resume\\.pdf|favicon\\.ico)|images/.*)',
headers: [{ key: 'Cross-Origin-Resource-Policy', value: 'cross-origin' }],
},
What about HSTS?
Vercel already sends Strict-Transport-Security: max-age=63072000 (two years) on custom domains. The scanner suggested adding includeSubDomains, but that applies HTTPS-only to every subdomain of your domain. Only add it once you're sure every subdomain serves HTTPS — and only add preload once you're sure you'll never go back.
How I tested it
curl -sIagainst the production build to confirm every header is present, including the CORP override on/opengraph-image.- Clicked through every page type with DevTools open: home with its WebGL background, case studies, blog posts, the generated OG images, and the admin, including its markdown editor. I was watching the console for
Refused to load…CSP violations. - Re-run the scanner once it's deployed, since the live response is the only one that counts. Vercel's own headers get merged with yours there.
Step 2 is the important one. A CSP that blocks your own analytics or your admin editor fails silently for users, and you only find out from the console.
Headers are one part of keeping a Next.js site safe. Keeping the framework patched is the other — see how I patched the October 2026 Next.js RCEs.