Skip to content
Draft, under reviewUpdated 2026-06-13
On this page

Most teams treat security as a phase. You ship, an audit hands you a list, and you spend a sprint patching things that should never have been wrong. I work from the opposite end. The defaults get chosen up front so the insecure version is the one you have to go out of your way to write. Get the defaults right and the audit finds nothing, because there was nothing to find.

This page is the spine of "secure by default": four rules with the reasoning behind each, the wrong way and right way side by side, then a walkthrough that hardens a typical app. Server Components moved data fetching onto the server. That is a real improvement, but it also moved the security boundary into a place a lot of frontend code never had to think about. The framework gives you good defaults. Your job is to not fight them.

The four rules

These are not aspirations. They are the load-bearing rules, and each one has a failure mode that ships to production when you ignore it.

Read secrets on the server only. A NEXT_PUBLIC_ prefix is a deliberate decision to print a value in the browser bundle, never an accident.

By default, environment variables live only on the server. Next.js inlines a variable into the client bundle only when its name starts with NEXT_PUBLIC_, and that inlining happens at build time. So the prefix is not a convenience toggle. It is a one-way door that bakes the value into shipped JavaScript where anyone can read it.

Avoid

lib/stripe.ts
// WRONG: this key is now inlined into the browser bundle.
// Every visitor can read your secret key in the page source.
const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY!)

Prefer

lib/stripe.ts
import 'server-only'
 
// Right: unprefixed, read on the server, never sent to the client.
// The server-only import makes a client import a build error.
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

Validate every input at the boundary, on the server, before it touches a database. Request data is untrusted until you have parsed it.

Form fields, search params, headers, and bracketed route segments like /[slug]/ are all user input. In Next.js 16 params is a Promise you await, and what you get back is still attacker-controlled. Trusting it directly is how you get privilege escalation from a query string.

Avoid

app/page.tsx
// WRONG: trusting a query param as an authority signal.
export default async function Page({
  searchParams,
}: {
  searchParams: Promise<{ isAdmin?: string }>
}) {
  const { isAdmin } = await searchParams
  if (isAdmin === 'true') {
    return <AdminPanel /> // anyone can append ?isAdmin=true
  }
}

Prefer

app/page.tsx
import { cookies } from 'next/headers'
import { verifyAdmin } from '@/lib/auth'
 
// Right: the authority signal comes from a verified server check,
// not from a value the client can type into the URL.
export default async function Page() {
  const token = (await cookies()).get('AUTH_TOKEN')
  const isAdmin = await verifyAdmin(token)
  if (isAdmin) {
    return <AdminPanel />
  }
}

Do not treat the UI as access control. A hidden button is not a permission check. The check that matters runs where the user cannot reach it.

This is the rule people violate without noticing, because the app looks correct. The admin link is hidden from non-admins, so it feels locked. But an exported Server Action is reachable by a direct POST request even if no component renders it, and a page-level auth check does not extend to the actions defined inside that page. The server entry point has to verify the caller on its own.

Avoid

app/admin/actions.ts
'use server'
import { db } from '@/lib/db'
 
// WRONG: no check inside the action. The page hiding the button
// does nothing. This is reachable by a direct POST.
export async function deleteAllRecords() {
  await db.record.deleteMany()
}

Prefer

app/admin/actions.ts
'use server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
 
export async function deleteAllRecords() {
  const session = await auth()
  if (!session?.user?.isAdmin) {
    throw new Error('Unauthorized') // the check that actually matters
  }
  await db.record.deleteMany()
}

Grant least privilege everywhere and check authorization, not just authentication. Logged in is not the same as allowed to touch this specific row.

Authentication asks "who are you". Authorization asks "are you allowed to act on this resource". Skipping the second question is how you get Insecure Direct Object Reference (IDOR): a logged-in user passes someone else's postId and your action happily deletes it. Every credential, database role, and access policy should grant the minimum needed and nothing more.

Input-handling rules, by context

The four rules tell you to validate input. These tell you exactly what to do for each kind of input you actually encounter, so the rule fires at the right moment instead of staying abstract. Each one is a trigger you watch for, the rule to apply, and the shape of the fix. Hand this list to an AI and it knows when each rule applies, not just that it exists.

Whenyou build a SQL query from any user-supplied value

Douse parameterized queries or the ORM's binding. Never string-concatenate or interpolate input into SQL.

data/users.ts
// AVOID: string interpolation is SQL injection.
const rows = await db.query(`SELECT * FROM users WHERE email = '${email}'`)
 
// PREFER: a parameter placeholder. The driver escapes it; input
// can never break out of the value position.
const rows = await db.query('SELECT * FROM users WHERE email = $1', [email])

Whenyou accept an email address

Dovalidate it with a schema, normalize the case, and treat it as data, never as a command or a file path.

lib/validation.ts
import { z } from 'zod'
 
// Validate the shape, then lowercase so 'A@x.com' and 'a@x.com'
// are one identity. Store and compare the normalized form.
const EmailInput = z.object({ email: z.string().email().toLowerCase() })
const { email } = EmailInput.parse(formData)

Whenyou render user-supplied content as HTML

Dolet React escape it by default. Only reach for dangerouslySetInnerHTML after sanitizing with a vetted library.

components/comment.tsx
// AVOID: raw HTML from a user is stored XSS.
<div dangerouslySetInnerHTML={{ __html: comment.body }} />
 
// PREFER: render as text (React escapes it). If you truly need
// HTML, sanitize first with a library like isomorphic-dompurify.
<div>{comment.body}</div>

Whenyou accept a file upload

Docheck the size and the real content type, store outside the web root with a generated name, and never trust the client-sent filename or MIME type.

app/api/upload/route.ts
const MAX = 5 * 1024 * 1024 // 5 MB ceiling
if (file.size > MAX) throw new Error('Too large')
 
// Trust the bytes, not the client's claim. Sniff the magic number,
// store under a random key, never the user's path.
const key = `${crypto.randomUUID()}.${extFromSniffedType(file)}`

Whenyou put a user value into a redirect, a shell command, or an outbound URL

Doallowlist it. Compare against known-safe values instead of trusting the input to be safe.

app/login/actions.ts
// AVOID: open redirect. ?next=//evil.com walks your users off-site.
redirect(searchParams.next)
 
// PREFER: only redirect to paths you recognize.
const safe = new Set(['/dashboard', '/settings', '/'])
redirect(safe.has(searchParams.next) ? searchParams.next : '/dashboard')

Whenyou expose an endpoint that sends email, costs money, or mutates data

Dorate-limit per user or IP. An unthrottled action is a spam relay and a billing-attack surface.

app/api/contact/route.ts
// Cap requests per identity per window before doing the work.
const ok = await rateLimit(`contact:${ip}`, { max: 5, windowSec: 60 })
if (!ok) return new Response('Too many requests', { status: 429 })

Hardening a typical app

The rules above describe the destination. Here is the path. Each step is a concrete change you can apply to an existing app, working from the outside in: lock the secrets, build the layer that says no, validate the inputs, control the outputs, then set the trust boundary for the proxy.

Lock secrets to the server with server-only

Audit which files read process.env or talk to your database. Those files should never be reachable from the client graph. Add the server-only import to each so a stray client import fails the build instead of leaking.

lib/db.ts
import 'server-only'
 
// Only this layer reads connection secrets. Nothing else imports
// process.env for data access, which keeps the blast radius small.
export const db = createClient({
  url: process.env.DATABASE_URL!,
  token: process.env.DATABASE_TOKEN!,
})

If you have any NEXT_PUBLIC_ variable holding something that is not genuinely public, rename it now and read it on the server. That prefix is the single most common way a real secret ends up in a bundle.

Build a Data Access Layer that authorizes by default

Centralize reads and writes behind one server-only module. The Data Access Layer (DAL) is where authentication, authorization, and the database query live together, so every path to the data goes through the same checks. This is the pattern Next.js recommends for new projects, and it is what makes an audit fast: a reviewer reads one layer instead of hunting through every component.

data/posts.ts
import 'server-only'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
 
export async function deletePost(postId: string) {
  const session = await auth()
  if (!session?.user) {
    throw new Error('Unauthorized') // authentication
  }
 
  const post = await db.post.findUnique({ where: { id: postId } })
  if (!post) {
    throw new Error('Not found')
  }
 
  // Authorization: prove ownership before mutating. This single
  // line is the difference between a safe action and an IDOR hole.
  if (post.authorId !== session.user.id) {
    throw new Error('Forbidden')
  }
 
  await db.post.delete({ where: { id: postId } })
}

Validate input where it enters the server

Parse untrusted input into a known shape before you use it. A schema at the boundary turns "I hope this is a string" into a guarantee. Do this inside the Server Action or the DAL, never on the client where the check can be skipped.

app/actions.ts
'use server'
import { z } from 'zod'
import { deletePost } from '@/data/posts'
import { revalidatePath } from 'next/cache'
 
const DeleteInput = z.object({ postId: z.string().uuid() })
 
export async function deletePostAction(formData: FormData) {
  // Reject malformed input before it reaches the DAL.
  const { postId } = DeleteInput.parse({
    postId: formData.get('postId'),
  })
 
  await deletePost(postId) // auth + authz live inside the DAL
  revalidatePath('/posts')
  return { success: true }
}

The action stays thin: validate, delegate, revalidate. All the security logic sits in the DAL where it is checked once and reused everywhere.

Control what leaves the server

Server Action return values are serialized and sent to the client, and props passed from a Server Component to a Client Component cross the same wire. Return only what the UI needs. A raw database record carries internal fields, soft-delete flags, and other people's foreign keys you never meant to publish.

data/posts.ts
import 'server-only'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
 
export async function getPostDTO(postId: string) {
  const session = await auth()
  if (!session?.user) {
    throw new Error('Unauthorized')
  }
 
  const post = await db.post.findUnique({ where: { id: postId } })
  if (post?.authorId !== session.user.id) {
    throw new Error('Forbidden')
  }
 
  // A Data Transfer Object: only the fields the client renders.
  return { id: post.id, title: post.title, body: post.body }
}

Set the trust boundary in proxy.ts

In Next.js 16 the file formerly known as middleware is proxy.ts. It runs on every matched request and has a lot of power, so it gets audited the hardest. Use it for coarse gatekeeping (redirect unauthenticated traffic away from a protected area), and keep the real per-resource authorization in the DAL where it belongs.

proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
 
export function proxy(request: NextRequest) {
  const token = request.cookies.get('AUTH_TOKEN')?.value
 
  // Coarse gate only. This is not authorization, it is a redirect
  // for visitors with no session. Ownership checks stay in the DAL.
  if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
 
  return NextResponse.next()
}
 
export const config = {
  matcher: ['/dashboard/:path*'],
}

Sources

  • Next.js docs01-app/02-guides/data-security.md