Authentication and data security

Next.js · Advanced · 15 min

The App Router blurs the client-server line, so security mistakes leak data or expose mutations easily.

Auth in Next.js is usually built with a library (Auth.js, Clerk, Better Auth and others) or your own session cookie: an encrypted or signed, httpOnly, secure, sameSite cookie set in a Server Action at login and read with cookies().

Where to check access:

  • Data Access Layer (DAL): a server-only module that verifies the session and exposes functions like getCurrentUser() and getInvoices(). Wrap the session check in React's cache() so it runs once per request. Every read of protected data goes through it.
  • Server Actions and Route Handlers: verify authentication and authorisation inside each one; they are public endpoints.
  • Proxy: only optimistic redirects (no cookie, go to login), never the sole check.
  • Layouts aren't a security boundary: they may not re-render on navigation, so check in pages or the DAL, close to the data.

Data exposure:

  • Return DTOs: only the fields the UI needs, not whole database rows with password hashes.
  • Remember that props passed to Client Components are sent to the browser.
  • React's experimental taint APIs (enabled with the taint config option) can block specific objects or values from ever crossing to the client.

Also validate all input with a schema library, rate-limit sensitive actions, and treat searchParams and params as untrusted.

Example

// lib/dal.ts - Data Access Layer
import 'server-only'
import { cache } from 'react'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { decrypt } from '@/lib/session'
import { db } from '@/lib/db'

export const verifySession = cache(async () => {
  const token = (await cookies()).get('session')?.value
  const session = await decrypt(token)            // verify signature and expiry
  if (!session?.userId) redirect('/login')
  return { userId: session.userId as string, role: session.role as string }
})

export async function getInvoice(id: string) {
  const { userId, role } = await verifySession()
  const invoice = await db.invoice.find(id)
  if (!invoice || (invoice.ownerId !== userId && role !== 'admin')) return null
  // DTO: only what the UI needs, never internal fields
  return { id: invoice.id, amount: invoice.amount, status: invoice.status }
}

Remember

  • Centralise session checks in a server-only Data Access Layer, cached per request
  • Authorise inside every Server Action and Route Handler; Proxy is optimistic only
  • Return minimal DTOs; everything passed to Client Components is public

Watch out for

  • Checking auth only in a layout, assuming it protects all child pages and their data.

Check yourself

  1. Why is checking authentication only in a shared layout insufficient?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: nextjs.org/docs/app/guides/data-security