How to Secure Your Next.js App: The Complete Checklist
A practical security checklist for Next.js apps with copy-paste code. Covers security headers, API routes, middleware auth, environment variables, Server Actions, and deployment.
Next.js is the default framework for most AI coding tools. If you built an app with Cursor, Lovable, v0, or Bolt, there's a good chance it runs on Next.js — and it almost certainly shipped without these security configurations.
This checklist is copy-paste ready. Each section has the code you need and explains why it matters.
1. Security Headers
Next.js doesn't add security headers by default. Add them in your next.config.ts:
// next.config.ts
const securityHeaders = [
{ key: 'Content-Security-Policy', value: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://your-api.com; frame-ancestors 'none'" },
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains; preload' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), payment=()' },
]
const nextConfig = {
poweredByHeader: false,
async headers() {
return [{ source: '/(.*)', headers: securityHeaders }]
},
}
export default nextConfig2. API Route Authentication
Every file in app/api/ is a public endpoint. Anyone can call it with curl. Every route that reads or modifies data must verify the session:
// app/api/user/settings/route.ts
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
export async function GET() {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
// Always filter by the authenticated user's ID
const settings = await db.settings.findUnique({
where: { userId: session.user.id }
})
return Response.json(settings)
}3. Middleware for Route Protection
Don't check auth in each page or API route individually. Use middleware to protect entire route groups:
// middleware.ts
import { getToken } from 'next-auth/jwt'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
const token = await getToken({ req: request })
// Protect dashboard and API routes
if (!token) {
if (request.nextUrl.pathname.startsWith('/api/')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/api/user/:path*', '/api/admin/:path*']
}4. Environment Variables
Next.js variables prefixed with NEXT_PUBLIC_ are bundled into client-side JavaScript and visible to anyone who views your page source. Audit your .env files for this:
- NEXT_PUBLIC_STRIPE_KEY — this must be the publishable key, never the secret key
- Database URLs, JWT secrets, API secret keys — these must NEVER have the NEXT_PUBLIC_ prefix
- Ensure .env files are in .gitignore — we've seen production database credentials in public repos
5. Server Actions
Every Server Action is a public API endpoint. The 'use server' directive doesn't mean it's protected — it means it runs on the server but can be called by anyone. Validate inputs and check auth in every server action:
'use server'
import { getServerSession } from 'next-auth'
import { z } from 'zod'
const updateProfileSchema = z.object({
name: z.string().min(1).max(100),
bio: z.string().max(500).optional(),
})
export async function updateProfile(formData: FormData) {
const session = await getServerSession()
if (!session?.user?.id) throw new Error('Unauthorized')
// Validate input — never trust client data
const parsed = updateProfileSchema.safeParse({
name: formData.get('name'),
bio: formData.get('bio'),
})
if (!parsed.success) throw new Error('Invalid input')
await db.user.update({
where: { id: session.user.id },
data: parsed.data,
})
}6. Server Components and Data Leaks
Server Components can safely access databases and secrets. But any props you pass to Client Components get serialized and sent to the browser. Don't pass full database records when the client only needs a few fields:
// BAD — sends everything to the client, including internal fields
// <UserCard user={fullUserRecord} />
// GOOD — only send what the client needs
<UserCard user={{ name: user.name, avatar: user.avatarUrl }} />7. Deployment
Final checks before shipping:
- poweredByHeader: false in next.config.ts (already in the headers config above)
- Verify environment variables are scoped correctly in Vercel (production vs. preview vs. development)
- Disable source maps in production if not needed: productionBrowserSourceMaps: false
- Ensure CORS is configured correctly if you have an API — don't use Access-Control-Allow-Origin: *
- Run an automated scan to verify everything is configured correctly
Verify your Next.js security setup — free scan at nullscan.io