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-onlymodule that verifies the session and exposes functions likegetCurrentUser()andgetInvoices(). Wrap the session check in React'scache()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
taintconfig 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-onlyData 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.