🔐 Chapter 18: Authentication & Authorization
Knowing who someone is, deciding what they're allowed to do, and putting the check where it can't be skipped.
📖 Plain English Explanation
Two words that get used interchangeably and shouldn't be.
Authentication — who are you? Verifying identity. Email and password, a magic link, "Sign in with Google". The output is a session.
Authorization — what may you do? Given that you're user 42, may you delete invoice 7? View the admin panel? Edit this comment?
Most security bugs live in the second one. Authentication is a solved problem you should mostly delegate to a library. Authorization is application logic, it's different in every app, and it's where "the button was hidden so nobody can click it" quietly becomes a data breach.
There's a third piece that's specific to the App Router and matters enormously:
Where the check runs. A Next.js app has four entry points — pages, layouts, Server Actions, and Route Handlers — and they do not share a chokepoint. A check in a layout doesn't protect a Server Action. A check in Proxy doesn't reliably protect anything. The only place that sees every path to your data is the data access itself.
That's the thesis of this chapter: put the check next to the data.
🎫 Sessions
A session is how the server remembers you between requests. Two designs.
Stateless: signed cookie (JWT)
The session data lives in the cookie, cryptographically signed so it can't be tampered with.
✅ No database lookup per request — fast
✅ Scales trivially
❌ Can't revoke until it expires
❌ Size limited (~4KB)
❌ Stale data (a role change won't apply until re-login)
Stateful: database session
The cookie holds only an opaque ID; the real data is in your database.
✅ Revoke instantly ("sign out all devices")
✅ Store as much as you like
✅ Always current
❌ A database read on every request
Practical advice: stateless for most apps, with short expiry and refresh. Stateful when you need instant revocation — anything handling money, health data, or admin capability.
A hybrid works well: a short-lived (15 min) stateless access token plus a long-lived stateful refresh token you can revoke.
🔨 Building it: stateless sessions
Enough to understand what libraries do for you. Use jose — it's Web Crypto based and works everywhere.
npm install jose bcryptjs
npm install -D @types/bcryptjs
Encrypt and decrypt
// lib/session.ts
import 'server-only'
import { SignJWT, jwtVerify } from 'jose'
import { cookies } from 'next/headers'
const secretKey = process.env.SESSION_SECRET
const encodedKey = new TextEncoder().encode(secretKey)
export type SessionPayload = {
userId: string
role: 'user' | 'admin'
expiresAt: number
}
export async function encrypt(payload: SessionPayload) {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(encodedKey)
}
export async function decrypt(token: string | undefined = '') {
try {
const { payload } = await jwtVerify(token, encodedKey, {
algorithms: ['HS256'],
})
return payload as SessionPayload
} catch {
return null // invalid, expired, or tampered
}
}
export async function createSession(userId: string, role: 'user' | 'admin') {
const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000
const token = await encrypt({ userId, role, expiresAt })
;(await cookies()).set('session', token, {
httpOnly: true, // JS can't read it
secure: process.env.NODE_ENV === 'production', // HTTPS only
sameSite: 'lax', // CSRF mitigation
expires: new Date(expiresAt),
path: '/',
})
}
export async function deleteSession() {
;(await cookies()).delete('session')
}
// lib/session.js
import 'server-only'
import { SignJWT, jwtVerify } from 'jose'
import { cookies } from 'next/headers'
const encodedKey = new TextEncoder().encode(process.env.SESSION_SECRET)
export async function encrypt(payload) {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(encodedKey)
}
export async function decrypt(token = '') {
try {
const { payload } = await jwtVerify(token, encodedKey, {
algorithms: ['HS256'],
})
return payload
} catch {
return null
}
}
export async function createSession(userId, role) {
const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000
const token = await encrypt({ userId, role, expiresAt })
;(await cookies()).set('session', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
expires: new Date(expiresAt),
path: '/',
})
}
export async function deleteSession() {
;(await cookies()).delete('session')
}
Every cookie option there is load-bearing:
| Option | Why |
|---|---|
httpOnly | Client JS can't read it — an XSS bug can't steal the session |
secure | Never sent over plain HTTP |
sameSite: 'lax' | Not sent on cross-site POSTs — blocks basic CSRF |
expires | Bounded lifetime |
path: '/' | Available to your whole app |
Also: algorithms: ['HS256'] in jwtVerify is not decoration. Without pinning the algorithm you're vulnerable to algorithm-confusion attacks.
🚪 Login and logout
// app/actions/auth.ts
'use server'
import { z } from 'zod'
import bcrypt from 'bcryptjs'
import { redirect } from 'next/navigation'
import { db } from '@/lib/db'
import { createSession, deleteSession } from '@/lib/session'
const loginSchema = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(1, 'Password is required'),
})
export type LoginState = { errors?: Record<string, string[]>; message?: string }
export async function login(
prev: LoginState,
formData: FormData
): Promise<LoginState> {
const parsed = loginSchema.safeParse({
email: formData.get('email'),
password: formData.get('password'),
})
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors }
}
const user = await db.user.findUnique({
where: { email: parsed.data.email },
select: { id: true, role: true, passwordHash: true },
})
// Same message and comparable timing whether the user exists or not
const valid =
user && (await bcrypt.compare(parsed.data.password, user.passwordHash))
if (!valid) {
return { message: 'Invalid email or password' }
}
await createSession(user.id, user.role)
redirect('/dashboard') // throws — must be last, outside try/catch
}
export async function logout() {
await deleteSession()
redirect('/login')
}
// app/actions/auth.js
'use server'
import { z } from 'zod'
import bcrypt from 'bcryptjs'
import { redirect } from 'next/navigation'
import { db } from '@/lib/db'
import { createSession, deleteSession } from '@/lib/session'
const loginSchema = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(1, 'Password is required'),
})
export async function login(prev, formData) {
const parsed = loginSchema.safeParse({
email: formData.get('email'),
password: formData.get('password'),
})
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors }
}
const user = await db.user.findUnique({
where: { email: parsed.data.email },
select: { id: true, role: true, passwordHash: true },
})
const valid =
user && (await bcrypt.compare(parsed.data.password, user.passwordHash))
if (!valid) {
return { message: 'Invalid email or password' }
}
await createSession(user.id, user.role)
redirect('/dashboard')
}
export async function logout() {
await deleteSession()
redirect('/login')
}
Two details worth calling out:
One error message for both failure modes. "No account with that email" is a user-enumeration oracle — an attacker can harvest valid addresses. Say "Invalid email or password" either way.
Rate limit this endpoint. A Server Action is a public endpoint; without a limit it's an unmetered password-guessing API.
import { headers } from 'next/headers'
import { rateLimit } from '@/lib/rate-limit'
export async function login(prev: LoginState, formData: FormData) {
const ip = (await headers()).get('x-forwarded-for') ?? 'unknown'
const { success } = await rateLimit(`login:${ip}`, { max: 5, window: '15m' })
if (!success) return { message: 'Too many attempts. Try again later.' }
// ...
}
The form:
// app/login/LoginForm.tsx
'use client'
import { useActionState } from 'react'
import { login, type LoginState } from '@/app/actions/auth'
export function LoginForm() {
const [state, formAction, isPending] = useActionState(login, {} as LoginState)
return (
<form action={formAction} className="space-y-4">
<input name="email" type="email" autoComplete="email" required />
{state.errors?.email && <p className="text-red-600">{state.errors.email[0]}</p>}
<input name="password" type="password" autoComplete="current-password" required />
{state.errors?.password && <p className="text-red-600">{state.errors.password[0]}</p>}
<button disabled={isPending}>{isPending ? 'Signing in…' : 'Sign in'}</button>
{state.message && <p className="text-red-600">{state.message}</p>}
</form>
)
}
🏛️ The Data Access Layer
This is the pattern that makes the whole thing hold together.
// lib/dal.ts
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'
/** Verify the session. Returns null when unauthenticated. */
export const verifySession = cache(async () => {
const token = (await cookies()).get('session')?.value
const session = await decrypt(token)
if (!session?.userId) return null
if (session.expiresAt < Date.now()) return null
return { userId: session.userId, role: session.role }
})
/** Same, but redirects instead of returning null. */
export const requireSession = cache(async () => {
const session = await verifySession()
if (!session) redirect('/login')
return session
})
/** The current user — safe fields only. */
export const getCurrentUser = cache(async () => {
const session = await verifySession()
if (!session) return null
return db.user.findUnique({
where: { id: session.userId },
select: { id: true, name: true, email: true, avatarUrl: true, role: true },
// ↑ never passwordHash, never internal flags
})
})
/** Authorization lives WITH the query — a caller cannot skip it. */
export const getInvoice = cache(async (id: string) => {
const session = await verifySession()
if (!session) return null
return db.invoice.findFirst({
where: {
id,
OR: [
{ userId: session.userId }, // owner
...(session.role === 'admin' ? [{ id }] : []), // or admin
],
},
})
})
export const listInvoices = cache(async () => {
const session = await verifySession()
if (!session) return []
return db.invoice.findMany({ where: { userId: session.userId } })
})
// lib/dal.js
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)
if (!session?.userId) return null
if (session.expiresAt < Date.now()) return null
return { userId: session.userId, role: session.role }
})
export const requireSession = cache(async () => {
const session = await verifySession()
if (!session) redirect('/login')
return session
})
export const getCurrentUser = cache(async () => {
const session = await verifySession()
if (!session) return null
return db.user.findUnique({
where: { id: session.userId },
select: { id: true, name: true, email: true, avatarUrl: true, role: true },
})
})
Why every piece matters:
| Piece | Why |
|---|---|
import 'server-only' | A client import becomes a build error, not a leaked secret |
cache() | Called in a layout, a page, and three components → one DB query |
| Auth inside the query | The where clause enforces ownership; there's no version of this call that skips it |
Explicit select | Password hashes and internal fields never leave the database |
That third point is the crux. Compare:
// ❌ authorization is the CALLER's responsibility — someone will forget
export async function getInvoice(id: string) {
return db.invoice.findUnique({ where: { id } })
}
// ✅ authorization is IN the query — impossible to forget
export const getInvoice = cache(async (id: string) => {
const session = await verifySession()
if (!session) return null
return db.invoice.findFirst({ where: { id, userId: session.userId } })
})
The second version cannot return someone else's invoice. Not "shouldn't" — cannot.
🛡️ Protecting the four entry points
Pages
// app/dashboard/page.tsx
import { requireSession } from '@/lib/dal'
import { listInvoices } from '@/lib/dal'
export default async function Page() {
await requireSession()
const invoices = await listInvoices()
return <InvoiceTable invoices={invoices} />
}
Layouts — UX, not security
// app/(app)/layout.tsx
import { getCurrentUser } from '@/lib/dal'
import { redirect } from 'next/navigation'
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const user = await getCurrentUser()
if (!user) redirect('/login') // bounce early — good UX
return (
<div className="flex">
<Sidebar user={user} />
<main>{children}</main>
</div>
)
}
This is a convenience. Layouts don't re-run on every client navigation, and Server Actions and Route Handlers never pass through them. The real check is still in the DAL.
Server Actions — always check
// app/actions/invoices.ts
'use server'
import { verifySession } from '@/lib/dal'
import { db } from '@/lib/db'
import { updateTag } from 'next/cache'
export async function deleteInvoice(id: string) {
const session = await verifySession()
if (!session) return { error: 'Not signed in' }
// Authorize the specific resource
const invoice = await db.invoice.findFirst({
where: { id, userId: session.userId },
select: { id: true },
})
if (!invoice) return { error: 'Not found' }
await db.invoice.delete({ where: { id } })
updateTag(`invoices-${session.userId}`)
return { success: true }
}
Remember from Chapter 12: a Server Action is a public HTTP endpoint. Anyone can POST to it. The button being hidden protects nothing.
Route Handlers — always check
// app/api/invoices/route.ts
import { verifySession } from '@/lib/dal'
import { db } from '@/lib/db'
export async function GET() {
const session = await verifySession()
if (!session) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const invoices = await db.invoice.findMany({
where: { userId: session.userId },
})
return Response.json(invoices)
}
Proxy — the optimistic layer
// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const PUBLIC = ['/login', '/register', '/forgot-password']
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
if (PUBLIC.includes(pathname)) return NextResponse.next()
if (!request.cookies.has('session')) {
const url = new URL('/login', request.url)
url.searchParams.set('from', pathname)
return NextResponse.redirect(url)
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}
It checks only that a cookie exists — it doesn't verify it. That's fine; that's the point. Cheap, fast, catches the common case, and never trusted.
👮 Authorization patterns
Role-based
// lib/authz.ts
import 'server-only'
import { verifySession } from '@/lib/dal'
import { redirect } from 'next/navigation'
export async function requireRole(role: 'admin' | 'editor') {
const session = await verifySession()
if (!session) redirect('/login')
if (session.role !== role) redirect('/403')
return session
}
// app/admin/page.tsx
import { requireRole } from '@/lib/authz'
export default async function Page() {
await requireRole('admin')
return <AdminPanel />
}
Ownership
The most common real check, and it belongs in the query:
export const getPost = cache(async (id: string) => {
const session = await verifySession()
if (!session) return null
return db.post.findFirst({
where: { id, authorId: session.userId },
})
})
With forbidden() and unauthorized()
Chapter 9's interrupts, if you've enabled experimental.authInterrupts:
// next.config.ts
const nextConfig: NextConfig = {
experimental: { authInterrupts: true },
}
// app/admin/page.tsx
import { unauthorized, forbidden } from 'next/navigation'
import { verifySession } from '@/lib/dal'
export default async function Page() {
const session = await verifySession()
if (!session) unauthorized() // 401 → app/unauthorized.tsx
if (session.role !== 'admin') forbidden() // 403 → app/forbidden.tsx
return <AdminPanel />
}
Still experimental in 16.3. redirect('/login') is the conservative choice for production.
🚫 Never leak, never trust
Don't pass whole records to Client Components
// ❌ passwordHash and every internal field land in the RSC payload
<Profile user={user} />
// ✅
<Profile name={user.name} avatar={user.avatarUrl} />
Anything crossing to a Client Component is visible in DevTools. Chapter 10 covered the taint API as a second layer.
Don't derive permissions on the client
// ❌ any user can flip this in DevTools
'use client'
export function Nav({ isAdmin }: { isAdmin: boolean }) {
return isAdmin ? <AdminLinks /> : null
}
Hiding the link is a UX nicety. If the admin route and its actions aren't checked server-side, the link's visibility is irrelevant.
Don't take the user ID from the client
// ❌ anyone can POST any userId
export async function updateProfile(formData: FormData) {
const userId = formData.get('userId') as string
await db.user.update({ where: { id: userId }, data: {...} })
}
// ✅ from the session, always
const session = await verifySession()
await db.user.update({ where: { id: session.userId }, data: {...} })
📚 Should you build this yourself?
Mostly, no.
| Library | Good for |
|---|---|
| Auth.js (NextAuth v5) | OAuth providers, open source, self-hosted, most flexible |
| Clerk | Fastest to ship; pre-built UI, orgs, MFA. Paid, hosted. |
| Better Auth | TypeScript-first, plugin architecture, self-hosted |
| Lucia | Minimal session primitives; you own the rest |
| Supabase / Firebase Auth | Already using their database |
Roll your own only when you have an unusual requirement and someone on the team who can own it. Password hashing, timing attacks, token rotation, CSRF, and OAuth state validation are all easy to get subtly wrong.
But: whichever library you use, authorization is still yours. Auth.js gives you a session. It has no opinion about whether user 42 may delete invoice 7. The DAL pattern in this chapter applies regardless.
Auth.js in outline
// auth.ts
import NextAuth from 'next-auth'
import GitHub from 'next-auth/providers/github'
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [GitHub],
})
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth'
export const { GET, POST } = handlers
// lib/dal.ts — same pattern, different session source
import 'server-only'
import { cache } from 'react'
import { auth } from '@/auth'
export const verifySession = cache(async () => {
const session = await auth()
return session?.user ? { userId: session.user.id, role: session.user.role } : null
})
Swap the session source; keep everything else.
🔒 Security checklist
Passwords
□ bcrypt or argon2 — never MD5, SHA1, or plain SHA256
□ minimum length enforced (12+)
□ checked against a breached-password list
□ same error message for "no such user" and "wrong password"
Sessions
□ httpOnly, secure, sameSite cookies
□ bounded expiry
□ regenerated on login (prevents session fixation)
□ deleted on logout
□ algorithms pinned in jwtVerify
Endpoints
□ login rate-limited per IP and per account
□ every Server Action checks the session
□ every Route Handler checks the session
□ target IDs come from the session, not the request body
□ input validated with a schema (no object spreads into the DB)
Data
□ explicit select — no SELECT *
□ only required fields cross to Client Components
□ server-only on every module touching the DB or secrets
□ authorization inside the query, not at the call site
Transport
□ HTTPS everywhere
□ security headers set (CSP, X-Frame-Options, HSTS)
□ SESSION_SECRET is long, random, and not in git
⚠️ Common Pitfalls
1. Checking only in the layout
Server Actions and Route Handlers don't pass through layouts. Client navigation doesn't re-run them.
2. Checking only in Proxy
A matcher edit silently drops coverage, and Server Actions can escape matchers entirely (Chapter 17).
3. Authorization at the call site instead of in the query
// ❌ one forgotten check = a data leak
const post = await getPost(id)
if (post.authorId !== session.userId) return null
// ✅
const post = await getPost(id) // the query already scopes it
4. Trusting client-supplied IDs
Covered above. This is the single most common real-world App Router vulnerability.
5. redirect() inside try/catch
try {
await createSession(user.id, user.role)
redirect('/dashboard') // ❌ swallowed
} catch (e) { }
6. Leaking the user record
passwordHash in an RSC payload is a resume-generating event. Always select.
7. User enumeration
Different messages for "no such account" and "wrong password" hands attackers a valid-email harvester.
8. No rate limiting
Server Actions are public endpoints. /login without a limit is a brute-force API you built for the attacker.
9. Not pinning the JWT algorithm
// ❌
await jwtVerify(token, key)
// ✅
await jwtVerify(token, key, { algorithms: ['HS256'] })
10. Session in localStorage
Reachable by any script on the page. Use httpOnly cookies.
🎯 When & Why to Use
Stateless sessions (JWT cookie) when:
✅ Standard app, moderate security requirements
✅ You want zero database reads per request
✅ Short expiry with refresh is acceptable
Stateful sessions (database) when:
✅ Instant revocation matters
✅ Finance, health, or admin-capable accounts
✅ "Sign out all devices" is a requirement
A library when:
✅ Almost always — Auth.js, Clerk, or Better Auth
Roll your own when:
✅ You have unusual requirements AND someone owns it
❌ Otherwise, don't
🏋️ Mini Practice Problems
Problem 1: Find the vulnerabilities
'use server'
export async function updateUser(formData: FormData) {
const data = Object.fromEntries(formData)
const user = await db.user.update({
where: { id: data.userId as string },
data,
})
return user
}
At least five. List them, then rewrite.
Problem 2: Where does the check go?
An app has:
/dashboardpage(app)/layout.tsxwith a sidebardeleteProjectServer Action/api/projectsRoute Handlerproxy.ts
A developer adds a session check only in the layout. Which of the other four are now exploitable, and how?
Problem 3: Design it
A team collaboration app where:
- Users belong to organizations
- Organizations have owners, admins, and members
- Only owners can delete the org
- Admins and owners can invite
- Members can only read
- A user may belong to several orgs
Design the DAL functions and the authorization approach. Where does the "which org am I acting in?" context come from, and why does that matter?
Problem 4: Fix the leak
export default async function Page() {
const user = await db.user.findUnique({ where: { id } })
return <ProfileEditor user={user} /> // 'use client'
}
What ends up in the browser? How would you confirm it? Give two independent fixes.
💼 Interview Notes
Common Questions
Q: Difference between authentication and authorization? Authentication verifies identity — who you are. Authorization decides permissions — what you may do. Authentication happens once per session; authorization happens on every operation.
Q: Where should auth checks live in a Next.js App Router app? In the Data Access Layer, next to the query. Pages, layouts, Server Actions, and Route Handlers are four independent entry points with no shared chokepoint. Only the data access sees all of them.
Q: Why isn't a check in a layout enough? Layouts don't re-run on every client navigation, and Server Actions and Route Handlers never render through them. Both are directly callable over HTTP.
Q: Why isn't a check in Proxy enough? It's optimistic — typically only "does a cookie exist". A matcher change can silently un-protect routes, and Server Actions are POSTs to their host route, so a matcher exclusion removes their coverage without any error.
Q: JWT sessions or database sessions? JWT: no per-request lookup, scales easily, but can't be revoked before expiry and carries stale data. Database: instant revocation and always-current data, at the cost of a read per request. Short-lived JWT plus a revocable refresh token is a common middle ground.
Q: What makes a session cookie secure?
httpOnly so scripts can't read it, secure so it's HTTPS-only, sameSite to mitigate CSRF, a bounded expires, and regeneration on login to prevent session fixation.
Q: How do you stop one user reading another's data?
Put the ownership constraint in the query — where: { id, userId: session.userId } — rather than fetching and then comparing. The unauthorized row is never returned, so there's no check to forget.
Q: Why does import 'server-only' matter for auth code?
It turns an accidental import from a Client Component into a build error, so session and database code can never be bundled into the browser.
🏢 Asked at Companies
- Stripe: "Here's a Server Action that deletes an invoice. Attack it."
- Vercel: "Explain the four entry points into a Next.js app and where auth must be enforced for each."
- Auth0: "Design a session system supporting 'sign out all devices'. What's the trade-off?"
- Shopify: "A multi-tenant app. How do you guarantee tenant A can never read tenant B's data?"
📊 Visual Memory Aid
FOUR DOORS, ONE LOCK
Page ──┐
Layout ─┤
Action ─┼──► ┌─────────────────┐
Handler ┘ │ Data Access │ ← the ONLY place
│ Layer │ that sees all four
Proxy ┈┈┈┈┈► │ verifySession() │
(optimistic) │ + query scoping │
└────────┬────────┘
▼
database
Proxy protects UX. The DAL protects data.
AUTHORIZATION IN THE QUERY
❌ const invoice = await db.invoice.findUnique({ where: { id } })
if (invoice.userId !== session.userId) return null
└── someone will forget this line
✅ await db.invoice.findFirst({
where: { id, userId: session.userId }
})
└── the wrong row is never returned
SESSION COOKIE FLAGS
httpOnly ──► XSS can't steal it
secure ──► never sent over HTTP
sameSite ──► not sent on cross-site POST (CSRF)
expires ──► bounded lifetime
path: '/' ──► available app-wide
THE ATTACKER'S VIEW
"The delete button only shows for admins"
└──► curl -X POST https://app.com/dashboard \
-H "Next-Action: <id>" -d '["invoice-42"]'
UI visibility is not access control.
🎯 Key Takeaways
- Authentication is who; authorization is what. Delegate the first to a library, own the second yourself — no library knows your permission rules.
- Put the check next to the data. Pages, layouts, Server Actions, and Route Handlers are four independent entry points; only the Data Access Layer sees them all.
- Encode authorization in the query, not at the call site.
where: { id, userId: session.userId }makes the wrong row unreturnable instead of merely unreturned. - Proxy and layout checks are UX. They bounce users early and feel fast. They are not security, and a matcher edit can silently remove them.
- Derive the acting user from the session, never from the request. Combined with
server-only, explicitselect, and rate limiting, that closes the overwhelming majority of real App Router vulnerabilities.
Next Chapter: Metadata, SEO & OG Images →
Practice: Build login, logout, and a protected dashboard with the DAL pattern. Then attack your own app: call the Server Action directly with curl, pass another user's ID, and inspect the RSC payload in DevTools for anything that shouldn't be there.