Next.js Security Headers That Actually Work
Six HTTP headers that take your Next.js site from Grade D to Grade A on securityheaders.com. Copy-paste ready. Works on Vercel.
If you scan a default Next.js app on securityheaders.com, you will usually see a D or an F. Not because your code is insecure, but because the platform ships with almost no HTTP security headers. This is how to fix that in one file.
The problem
A vanilla Next.js deployment will often show something like:
- ❌ No
Content-Security-Policy - ❌ No
X-Frame-Options - ❌ No
X-Content-Type-Options - ❌ No
Referrer-Policy - ❌ No
Permissions-Policy - ⚠️ Weak or missing
Strict-Transport-Security
Those headers are the boring stuff that blocks cross-site scripting, clickjacking, MIME sniffing and a bunch of "why is this even allowed" browser behaviour.
Your app still works without them – so do most attacks.
The fix: proxy.ts
Next.js 16 introduced the proxy pattern (replacing the old middleware trick) as
the recommended way to intercept requests and responses. It is also the most
reliable place to set headers on Vercel.
Create proxy.ts in your project root:
import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; export function proxy(request: NextRequest) { const response = NextResponse.next(); // Content Security Policy response.headers.set( "Content-Security-Policy", [ "default-src 'self'", "script-src 'self' 'unsafe-inline'", "style-src 'self' 'unsafe-inline'", "img-src 'self' data: https:", "font-src 'self' data:", "connect-src 'self' https://api.openai.com", "frame-ancestors 'none'", ].join("; ") ); // Prevent clickjacking response.headers.set("X-Frame-Options", "DENY"); // Prevent MIME sniffing response.headers.set("X-Content-Type-Options", "nosniff"); // Control referrer information response.headers.set( "Referrer-Policy", "strict-origin-when-cross-origin" ); // Restrict browser features response.headers.set( "Permissions-Policy", "camera=(), microphone=(), geolocation=()" ); // Force HTTPS response.headers.set( "Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload" ); return response; } export const config = { matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"], };
Deploy. Scan again. You should be looking at Grade A.
What each header does (and why you care)
Content-Security-Policy (CSP)
CSP tells the browser where it is allowed to load things from. It is your main defence against XSS.
"default-src 'self'" // only your own origin "script-src 'self' 'unsafe-inline'" // allow inline scripts (Next.js needs this) "style-src 'self' 'unsafe-inline'" // allow inline styles "img-src 'self' data: https:" // data URLs + HTTPS images "font-src 'self' data:" // local + data fonts "connect-src 'self' https://api.openai.com" // API calls "frame-ancestors 'none'" // nobody gets to iframe you
Why 'unsafe-inline'?
Next.js injects inline scripts for hydration. Removing it without going all-in on
CSP nonces usually means "random things break in production". For most apps,
a strict default-src plus a small set of allowed script origins is already a big win.
X-Frame-Options
Prevents your app from being embedded in an iframe, which is how classic clickjacking attacks work.
response.headers.set("X-Frame-Options", "DENY");
If you actually need to embed your own pages, switch to SAMEORIGIN. Otherwise,
DENY is the boring safe choice.
X-Content-Type-Options
Stops browsers from guessing content types.
response.headers.set("X-Content-Type-Options", "nosniff");
With nosniff, if you say "this is CSS", the browser treats it as CSS – not
"maybe JavaScript if I squint".
Referrer-Policy
Controls how much URL information is sent when users navigate away from your site.
response.headers.set( "Referrer-Policy", "strict-origin-when-cross-origin" );
Full URLs are sent between pages on your own site. External sites see only the origin. Analytics still work, privacy is better.
Permissions-Policy
Turns off powerful browser features you are not using.
response.headers.set( "Permissions-Policy", "camera=(), microphone=(), geolocation=()" );
If you are not building Maps + Webcam Chat Deluxe™, there is no reason random third-party scripts should be able to ask for camera or location.
Strict-Transport-Security (HSTS)
Tells browsers to only ever talk to you over HTTPS.
response.headers.set( "Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload" );
max-age=63072000→ remember this for two yearsincludeSubDomains→ also force HTTPS forfoo.yoursite.compreload→ opt-in to the browser preload lists
Once this is out in the wild, you should treat HTTP as gone for that domain.
Council advice: resist the urge to over-tune
This setup is intentionally boring. It works for most marketing sites, SaaS frontends and dashboards. Start by shipping exactly this. Only change it when you have a specific need: an analytics script, a payment provider, or a page that really needs to live inside an iframe. When something breaks, add the minimum extra permission to the relevant directive. Grade A is the goal, not "most creative CSP on the block".
Why proxy.ts instead of next.config.js headers?
You can set headers in next.config.js, but those are applied at build time and can behave differently across static/dynamic routes.
proxy.ts runs on every request at the edge, so what you see in curl -I is what users actually get. One place, predictable behaviour.
Adjusting CSP for your stack
The sample CSP assumes:
- self-hosted assets
- calls to the OpenAI API
- no external script CDNs
Add the domains you really need:
// Analytics "script-src 'self' 'unsafe-inline' https://www.googletagmanager.com", "connect-src 'self' https://www.google-analytics.com", // Stripe "connect-src 'self' https://api.stripe.com", // Image CDN "img-src 'self' data: https: https://images.example-cdn.com",
Keep the list short. CSP is only useful if it is opinionated.
Testing your headers
1. Deploy to Vercel
Push your changes, wait for the deployment to finish.
2. Scan with securityheaders.com
Go to https://securityheaders.com, enter your production URL:
https://yoursite.com
You should see Grade A with the headers above listed as present.
3. Sanity check with curl
curl -I https://yoursite.com \ | grep -E "content-security-policy|x-frame-options|x-content-type-options|strict-transport-security"
You should see all of them in the response. If not, purge Vercel cache and redeploy.
Common issues
"The headers are not showing up"
Make sure you are hitting production (not a preview) and that your CDN cache is cleared. Redeploy if needed.
"Something stopped working after CSP"
Your CSP is blocking it. Add only what you need:
// External script "script-src 'self' 'unsafe-inline' https://cdn.example.com" // External API "connect-src 'self' https://api.example.com"
Reload with DevTools → Network → "Disable cache" ticked when testing.
"The app fails to hydrate"
You probably removed 'unsafe-inline' from script-src.
Put it back unless you are ready to go down the nonce rabbit hole.
When to go beyond Grade A
Grade A is already a big step up from default settings.
You only really need more when:
- you handle money, health data or similar
- you are under compliance regimes (PCI, HIPAA, etc.)
- you have people whose job title includes "security engineer"
Then you can start looking at:
- CSP nonces instead of
'unsafe-inline' - Subresource Integrity (SRI) for third-party scripts
- Certificate Transparency monitoring
For everyone else: this file gets you 95 % of the win for 5 % of the effort. The remaining 5 % is how security teams justify buying more dashboards.
Copy-paste checklist
- [ ] Add
proxy.tsto the project root - [ ] Paste the header setup from this article
- [ ] Add any external APIs/CDNs to
connect-src/script-src/img-src - [ ] Deploy to Vercel
- [ ] Verify with
curl -I - [ ] Verify Grade A on securityheaders.com
Done. Your headers are no longer the weakest part of your stack.
Resources
- securityheaders.com - quick scan
- MDN: Content-Security-Policy
- Next.js proxy docs
- OWASP Secure Headers
Six headers. One file. Boring security that actually holds up.