Next.js Clickjacking Protection
Setting X-Frame-Options and CSP frame-ancestors in Next.js.
Quick config
Add an async headers() function to next.config.mjs. It returns an array of route-matched header rules.
// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'Content-Security-Policy',
value: "frame-ancestors 'none'",
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
],
},
];
},
};
export default nextConfig;One header is enough. CSP frame-ancestors is the modern control; X-Frame-Options is a legacy fallback for older browsers. The Next.js docs note that X-Frame-Options "has been superseded by CSP's frame-ancestors option."
If you embed your own pages in iframes, replace 'none' with 'self' and DENY with SAMEORIGIN.
Source path patterns
The source field controls which routes the rule applies to. Two patterns match every request:
/(.*)-- a regex wildcard that matches all paths./:path*-- a named parameter with a*modifier that also matches everything.
Use /(.*) for site-wide headers so the intent is explicit. Reserve named parameters for cases where you consume the captured segment elsewhere (e.g. reading :path in a redirect).
Headers are checked before the filesystem, meaning your rules apply even to static files in public/. This is usually what you want for security headers.
Conditional headers with has and missing
A header rule can include has and missing conditions. The rule only applies when every has condition matches and every missing condition is absent. Conditions can check request headers, cookies, the hostname, or query parameters.
// next.config.mjs -- skip the framing header when the
// "embed" cookie is present (for embeddable widgets)
async headers() {
return [
{
source: '/(.*)',
missing: [
{ type: 'cookie', key: 'embed' },
],
headers: [
{ key: 'Content-Security-Policy', value: "frame-ancestors 'none'" },
{ key: 'X-Frame-Options', value: 'DENY' },
],
},
];
}A client-controlled cookie or header is a weak gate -- the attacker's page can influence it. For real security decisions, use middleware (see below), which can check server-side state like authentication.
Middleware (dynamic header logic)
When framing rules depend on runtime state -- the request path, auth status, or anything not expressible as a static path pattern -- use middleware.ts. Middleware runs on every matching request and can set headers programmatically.
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const response = NextResponse.next();
const isEmbeddable = request.nextUrl.pathname.startsWith('/embed');
if (isEmbeddable) {
response.headers.set('Content-Security-Policy', "frame-ancestors 'self'");
response.headers.set('X-Frame-Options', 'SAMEORIGIN');
} else {
response.headers.set('Content-Security-Policy', "frame-ancestors 'none'");
response.headers.set('X-Frame-Options', 'DENY');
}
return response;
}
export const config = {
matcher: '/:path*',
};Middleware runs on the Edge Runtime by default. Setting headers needs no Node.js APIs, so this works without configuration.
Priority. Middleware runs after next.config.mjs headers are applied. If both set the same header, middleware wins. Decide who owns the header and do not split the same header across both layers.
Vercel deployment (vercel.json)
For non-Next.js projects on Vercel, or when you prefer headers defined outside the framework, use the headers array in vercel.json. Vercel applies these at the edge.
// vercel.json
{
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Content-Security-Policy",
"value": "frame-ancestors 'none'"
},
{
"key": "X-Frame-Options",
"value": "DENY"
}
]
}
]
}The source syntax mirrors next.config.mjs. For a Next.js app, you do not need both -- pick one file and stick with it.
Gotchas
Static and dynamic routes
headers() applies to all responses -- statically generated pages, server-rendered pages, and API routes. Test both a page route and an /api/* endpoint. A header that appears on / does not guarantee it appears on /api/health.
Cache-Control on static assets
Next.js sets an immutable Cache-Control header on hashed static assets. You cannot override it. This does not affect your security headers -- frame-ancestors and X-Frame-Options are unrelated to caching.
basePath
If next.config.mjs sets basePath, all header source values are automatically prefixed with it. To match paths without the prefix, set basePath: false on the individual rule.
Internationalized routing
With i18n routing enabled, source paths are auto-prefixed with the locale. Set locale: false on the rule to match paths as-written.
Verify it is working
After deploying, check that the headers are present. Test both a page and an API route:
curl -I https://your-app.vercel.app/
curl -I https://your-app.vercel.app/api/healthLook for Content-Security-Policy: frame-ancestors 'none' or X-Frame-Options: DENY in the output.
Then run it through ClickJack Test to confirm.