Dev Logs
/Next.js/ Chapter 18: Authentication & Authorization
Chapters
  • 01Chapter 1: Introduction & Setup
  • 02Chapter 2: Project Structure & Configuration
  • 03Chapter 3: Layouts & Pages
  • 04Chapter 4: Linking & Navigation
  • 05Chapter 5: Dynamic Routes & Params
  • 06Chapter 6: Route Groups & Organization
  • 07Chapter 7: Parallel & Intercepting Routes
  • 08Chapter 8: Loading, Suspense & Streaming
  • 09Chapter 9: Error Handling
  • 10Chapter 10: Server & Client Components
  • 11Chapter 11: Data Fetching
  • 12Chapter 12: Server Actions & Mutations
  • 13Chapter 13: Route Handlers
  • 14Chapter 14: Caching & use cache
  • 15Chapter 15: Revalidation & ISR
  • 16Chapter 16: Cache Components & Partial Prerendering
  • 17Chapter 17: Proxy (formerly Middleware)
  • 18Chapter 18: Authentication & Authorization
    • Plain English Explanation
    • Sessions
    • Stateless: signed cookie (JWT)
    • Stateful: database session
    • Building it: stateless sessions
    • Encrypt and decrypt
    • Login and logout
    • The Data Access Layer
    • Protecting the four entry points
    • Pages
    • Layouts — UX, not security
    • Server Actions — always check
    • Route Handlers — always check
    • Proxy — the optimistic layer
    • Authorization patterns
    • Role-based
    • Ownership
    • With forbidden() and unauthorized()
    • Never leak, never trust
    • Don't pass whole records to Client Components
    • Don't derive permissions on the client
    • Don't take the user ID from the client
    • Should you build this yourself?
    • Auth.js in outline
    • Security checklist
    • Common Pitfalls
    • . Checking only in the layout
    • . Checking only in Proxy
    • . Authorization at the call site instead of in the query
    • . Trusting client-supplied IDs
    • . redirect() inside try/catch
    • . Leaking the user record
    • . User enumeration
    • . No rate limiting
    • . Not pinning the JWT algorithm
    • . Session in localStorage
    • When & Why to Use
    • Mini Practice Problems
    • Problem 1: Find the vulnerabilities
    • Problem 2: Where does the check go?
    • Problem 3: Design it
    • Problem 4: Fix the leak
    • Interview Notes
    • Common Questions
    • Asked at Companies
    • Visual Memory Aid
    • Key Takeaways
  • 19Chapter 19: Metadata, SEO & OG Images
  • 20Chapter 20: Images & Fonts
  • 21Chapter 21: Styling
  • 22Chapter 22: Performance Optimization
  • 23Chapter 23: Testing
  • 24Chapter 24: Deployment & Self-Hosting
  • 25Chapter 25: Upgrading to Next.js 16
  • 26Chapter 26: Capstone Project
  • 27Chapter 27: React Performance Profiling
All chapters

🔐 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.

bash
npm install jose bcryptjs
npm install -D @types/bcryptjs

Encrypt and decrypt

ts
// 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')
}
js
// 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:

OptionWhy
httpOnlyClient JS can't read it — an XSS bug can't steal the session
secureNever sent over plain HTTP
sameSite: 'lax'Not sent on cross-site POSTs — blocks basic CSRF
expiresBounded 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

ts
// 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')
}
js
// 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.

ts
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:

tsx
// 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.

ts
// 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 } })
})
js
// 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:

PieceWhy
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 queryThe where clause enforces ownership; there's no version of this call that skips it
Explicit selectPassword hashes and internal fields never leave the database

That third point is the crux. Compare:

ts
// ❌ 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

tsx
// 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

tsx
// 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

ts
// 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

ts
// 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

ts
// 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

ts
// 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
}
tsx
// 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:

ts
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:

ts
// next.config.ts
const nextConfig: NextConfig = {
  experimental: { authInterrupts: true },
}
tsx
// 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

tsx
// ❌ 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

tsx
// ❌ 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

ts
// ❌ 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.

LibraryGood for
Auth.js (NextAuth v5)OAuth providers, open source, self-hosted, most flexible
ClerkFastest to ship; pre-built UI, orgs, MFA. Paid, hosted.
Better AuthTypeScript-first, plugin architecture, self-hosted
LuciaMinimal session primitives; you own the rest
Supabase / Firebase AuthAlready 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

ts
// auth.ts
import NextAuth from 'next-auth'
import GitHub from 'next-auth/providers/github'

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [GitHub],
})
ts
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth'
export const { GET, POST } = handlers
ts
// 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

ts
// ❌ 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

ts
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

ts
// ❌
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

ts
'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:

  • /dashboard page
  • (app)/layout.tsx with a sidebar
  • deleteProject Server Action
  • /api/projects Route Handler
  • proxy.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

tsx
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

  1. Authentication is who; authorization is what. Delegate the first to a library, own the second yourself — no library knows your permission rules.
  2. 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.
  3. Encode authorization in the query, not at the call site. where: { id, userId: session.userId } makes the wrong row unreturnable instead of merely unreturned.
  4. 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.
  5. Derive the acting user from the session, never from the request. Combined with server-only, explicit select, 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.


PreviousChapter 17: Proxy (formerly Middleware)NextChapter 19: Metadata, SEO & OG Images

Open source, free forever. Built by iammhador.

Contribute on GitHub