Deepen PracticeOrdered learning track

Next.js App Router Auth

Learn React Authentication, Authorization, Identity & Permission/ACL - Part 073

Next.js App Router authentication architecture for React engineers: Server Components, Client Components, Route Handlers, Server Actions, cookies, session projection, authorization placement, cache-control, and production failure modes.

9 min read1679 words
PrevNext
Lesson 73130 lesson track72–107 Deepen Practice
#react#nextjs#app-router#authentication+12 more

Part 073 — Next.js App Router Auth

The App Router changes the auth conversation.

In a pure SPA, the browser often owns the auth flow, route guard, data fetch, token refresh, cache invalidation, and UI rendering. In App Router, the system is split across several execution surfaces:

Server Components
Client Components
Route Handlers
Server Actions / Server Functions
Middleware
Browser fetches
Downstream APIs
Auth provider / IdP
Session store
Policy engine

That split is powerful, but it also creates new failure modes.

The correct question is not:

How do I protect a page in Next.js?

The correct question is:

At which runtime should authentication, session loading, authorization, data fetching, mutation, cache-control, and UI projection happen?

A top-tier React engineer treats App Router auth as a set of explicit boundaries.


1. The App Router auth mental model

App Router gives you server-side rendering and React Server Components, but this does not automatically make the application secure.

It gives you places where security logic can be placed closer to the server.

That is different.

The shape of a secure App Router auth system:

Browser owns interaction.
Server owns session interpretation.
Server owns protected data loading.
Server owns mutation authorization.
Client Components receive safe projections.
Route Handlers and Server Actions enforce again.

The primary rule:

Do not confuse server rendering with server authorization.

A Server Component can render protected data safely only if the data source was authorized correctly.


2. Runtime surfaces and their security responsibilities

SurfaceRuns where?Good forDangerous if used for
Server ComponentServerReading session, loading authorized data, rendering safe projectionPassing sensitive objects into Client Components
Client ComponentBrowserInteraction, local UI state, optimistic UXHolding secrets, making final auth decisions
Route HandlerServerAPI endpoint/BFF, cookie/session work, downstream callsReturning over-broad data, missing per-request auth
Server Action / Server FunctionServerForm/mutation handlingTrusting hidden fields or client-submitted permission flags
MiddlewareEdge/server before routeCoarse redirects, request decorationFine-grained authorization requiring fresh resource data
LayoutServer or client depending on boundaryShared shell/session projectionAssuming shell visibility equals route authorization
Metadata/menu configBuild/runtimeNavigation exposureEnforcement

The invariant:

Every protected data read and every mutation needs a server-side authorization decision.

A layout that hides a menu item is useful. It is not enforcement.


3. Authentication, session management, and authorization are separate

In App Router, it is tempting to build a single function called auth() and let it do everything.

That usually becomes a vague god function.

Separate the concerns:

export type AuthenticatedSession = {
  kind: 'authenticated'
  sessionId: string
  userId: string
  tenantId: string
  authTime: string
  assuranceLevel: 'aal1' | 'aal2' | 'aal3'
  permissionEpoch: number
}

export type AnonymousSession = {
  kind: 'anonymous'
}

export type SessionProjection = AuthenticatedSession | AnonymousSession

Then layer the helpers:

export async function readSession(): Promise<SessionProjection> {
  // Parse cookie, read server-side session store, return minimal projection.
}

export async function requireUser(): Promise<AuthenticatedSession> {
  const session = await readSession()

  if (session.kind !== 'authenticated') {
    throw new UnauthorizedError('login_required')
  }

  return session
}

export async function requirePermission(input: {
  session: AuthenticatedSession
  action: string
  resource: { type: string; id?: string }
  context?: Record<string, unknown>
}) {
  const decision = await policyEngine.check(input)

  if (!decision.allowed) {
    throw new ForbiddenError(decision.reason.code)
  }

  return decision
}

This gives you composable primitives:

readSession       -> projection
requireUser       -> authentication gate
requirePermission -> authorization gate

That separation prevents many bugs:

Authenticated but forbidden
Authenticated but needs step-up
Authenticated but wrong tenant
Authenticated but stale permission epoch
Anonymous but route can still render public shell

4. Where to read cookies

A common App Router pattern:

import { cookies } from 'next/headers'

export async function readSessionCookie() {
  const cookieStore = await cookies()
  return cookieStore.get('__Host-app_session')?.value ?? null
}

But reading a cookie is not reading a session.

Cookie value is only a pointer or credential. The server still needs to:

1. Parse cookie.
2. Validate signature or lookup opaque session id.
3. Check server-side expiration.
4. Check revocation.
5. Check tenant and assurance context.
6. Return a minimal projection.

Recommended cookie design:

__Host-app_session=<opaque-session-id>
Secure
HttpOnly
SameSite=Lax or Strict depending on flow
Path=/
No Domain attribute

The React app should receive this:

{
  "kind": "authenticated",
  "user": {
    "id": "usr_123",
    "displayName": "Asha"
  },
  "tenant": {
    "id": "tenant_456",
    "name": "Acme Risk"
  },
  "permissionEpoch": 91
}

Not this:

{
  "accessToken": "eyJhbGciOi...",
  "refreshToken": "...",
  "rawIdToken": "...",
  "allProviderClaims": { }
}

In App Router, the whole point of server-side execution is to reduce what must be exposed to JavaScript.


5. Root layout is not always the right place for auth

A root layout is attractive because it wraps everything.

But making root layout require authentication has consequences:

Public pages become harder.
Marketing pages may depend on auth runtime.
Static optimization may be lost.
Logout/login loops can become global.
Every route inherits a heavy session dependency.

Better structure:

app/
  layout.tsx                    # public root shell
  page.tsx                      # public home
  login/page.tsx                # public login
  (authenticated)/
    layout.tsx                  # require session
    dashboard/page.tsx
    cases/[caseId]/page.tsx
  api/
    session/route.ts            # session projection endpoint if needed

Example authenticated layout:

// app/(authenticated)/layout.tsx
import { requireUser } from '@/auth/server-session'
import { AuthenticatedShell } from '@/components/authenticated-shell'

export default async function AuthenticatedLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const session = await requireUser()

  return (
    <AuthenticatedShell session={toSafeSessionProjection(session)}>
      {children}
    </AuthenticatedShell>
  )
}

This protects the shell. It does not automatically authorize every child resource.

The page must still check resource access:

// app/(authenticated)/cases/[caseId]/page.tsx
import { requireUser, requirePermission } from '@/auth/server-session'
import { getCaseForUser } from '@/cases/data'

export default async function CasePage({
  params,
}: {
  params: Promise<{ caseId: string }>
}) {
  const { caseId } = await params
  const session = await requireUser()

  await requirePermission({
    session,
    action: 'case.view',
    resource: { type: 'case', id: caseId },
  })

  const record = await getCaseForUser({
    caseId,
    tenantId: session.tenantId,
    userId: session.userId,
  })

  return <CaseView caseRecord={toSafeCaseView(record)} />
}

The layout proves the user is authenticated.

The page proves the user may access that resource.


6. Route Handlers as BFF endpoints

Route Handlers are a natural place to implement BFF-style endpoints.

Example:

// app/api/cases/[caseId]/route.ts
import { NextResponse } from 'next/server'
import { requireUser, requirePermission } from '@/auth/server-session'
import { getCaseDetails } from '@/cases/service'

export async function GET(
  _request: Request,
  context: { params: Promise<{ caseId: string }> }
) {
  const { caseId } = await context.params
  const session = await requireUser()

  await requirePermission({
    session,
    action: 'case.view',
    resource: { type: 'case', id: caseId },
  })

  const data = await getCaseDetails({
    caseId,
    tenantId: session.tenantId,
  })

  return NextResponse.json(toCaseDetailsDto(data), {
    headers: {
      'Cache-Control': 'no-store',
    },
  })
}

The handler contract:

Do not trust path params.
Do not trust query params.
Do not trust request body.
Do not trust cookies without server validation.
Do not trust Client Component state.
Authorize inside the handler.
Return least-data DTOs.
Set cache-control intentionally.

For mutations:

export async function POST(
  request: Request,
  context: { params: Promise<{ caseId: string }> }
) {
  const { caseId } = await context.params
  const session = await requireUser()
  const body = await request.json()

  await assertCsrf(request, session)

  await requirePermission({
    session,
    action: 'case.escalate',
    resource: { type: 'case', id: caseId },
    context: {
      requestedTransition: body.transition,
    },
  })

  const result = await escalateCase({
    caseId,
    tenantId: session.tenantId,
    actorId: session.userId,
    transition: body.transition,
    reason: body.reason,
  })

  return NextResponse.json(toMutationResult(result), {
    status: 200,
    headers: {
      'Cache-Control': 'no-store',
    },
  })
}

Never let the client send this as authority:

{
  "canEscalate": true,
  "role": "supervisor",
  "tenantId": "tenant_456"
}

The client can submit intent. The server decides.


7. Server Actions for protected mutations

Server Actions can be a good fit for forms, but they are not magic.

A Server Action still receives user-controlled input.

Bad:

'use server'

export async function approveCase(formData: FormData) {
  const caseId = String(formData.get('caseId'))
  const approverRole = String(formData.get('role'))

  if (approverRole === 'supervisor') {
    await approve(caseId)
  }
}

The hidden field is not authority.

Better:

'use server'

import { revalidatePath } from 'next/cache'
import { requireUser, requirePermission } from '@/auth/server-session'
import { approveCaseCommand } from '@/cases/commands'

export async function approveCaseAction(
  _previousState: unknown,
  formData: FormData
) {
  const session = await requireUser()
  const caseId = String(formData.get('caseId') ?? '')
  const reason = String(formData.get('reason') ?? '')

  await requirePermission({
    session,
    action: 'case.approve',
    resource: { type: 'case', id: caseId },
    context: {
      reasonProvided: reason.length > 0,
    },
  })

  const result = await approveCaseCommand({
    actorId: session.userId,
    tenantId: session.tenantId,
    caseId,
    reason,
  })

  revalidatePath(`/cases/${caseId}`)

  return {
    ok: true,
    caseVersion: result.version,
  }
}

Server Action rules:

Treat formData as hostile.
Load session inside the action.
Authorize inside the action.
Validate state transition server-side.
Emit audit event server-side.
Revalidate cache intentionally.
Return safe result only.

A Server Action is an execution location, not an authorization policy.


8. Server Components and safe data projection

Server Components allow direct server-side data loading. That can remove unnecessary client fetches.

But it also means the component tree may accidentally become a data exfiltration path.

Bad:

export default async function AccountPage() {
  const account = await db.account.findUnique({
    include: {
      billingProfile: true,
      internalRiskFlags: true,
      authProviderLinks: true,
      supportNotes: true,
    },
  })

  return <AccountClient account={account} />
}

If AccountClient is a Client Component, the serialized props cross into the browser.

Better:

export default async function AccountPage() {
  const session = await requireUser()

  await requirePermission({
    session,
    action: 'account.view',
    resource: { type: 'account', id: session.tenantId },
  })

  const account = await loadAccountForPage({
    tenantId: session.tenantId,
    actorId: session.userId,
  })

  return <AccountClient account={toAccountPageDto(account)} />
}

DTO projection is not ceremony. It is a safety barrier.

type AccountPageDto = {
  id: string
  displayName: string
  planName: string
  canManageBilling: boolean
}

function toAccountPageDto(account: AccountRecord): AccountPageDto {
  return {
    id: account.id,
    displayName: account.displayName,
    planName: account.plan.publicName,
    canManageBilling: account.allowedActions.includes('billing.manage'),
  }
}

The DTO should be shaped for UI, not copied from persistence.


9. use client is a serialization boundary

Once you pass data into a Client Component, assume it is visible to the browser.

'use client'

export function BillingPanel(props: {
  account: AccountPageDto
}) {
  return <section>{props.account.planName}</section>
}

Safe enough if the DTO is safe.

Dangerous if props contain:

raw access tokens
refresh tokens
ID tokens
session ids
provider claims not meant for UI
internal policy traces
audit-only reasons
internal notes
hidden fraud/risk signals
full database records

Mental model:

Server Component -> may hold secrets temporarily
Client Component -> must not receive secrets

Do not rely on “not rendering it” in the Client Component.

If it is in props, it is already exposed.


10. Auth boundary with route groups

A scalable App Router project often uses route groups as auth domains.

app/
  (public)/
    login/page.tsx
    forgot-password/page.tsx
  (authenticated)/
    layout.tsx
    dashboard/page.tsx
    cases/[caseId]/page.tsx
  (admin)/
    layout.tsx
    users/page.tsx
    policies/page.tsx

But route groups are organizational. They are not security by themselves.

Use them to colocate auth boundaries:

// app/(admin)/layout.tsx
import { requireUser, requirePermission } from '@/auth/server-session'

export default async function AdminLayout({ children }: { children: React.ReactNode }) {
  const session = await requireUser()

  await requirePermission({
    session,
    action: 'admin.console.access',
    resource: { type: 'tenant', id: session.tenantId },
  })

  return <>{children}</>
}

Then still authorize individual admin actions:

admin.console.access       -> can enter admin shell
user.invite                -> can invite users
role.assign                -> can assign roles
policy.publish             -> can publish policy changes
impersonation.start        -> can impersonate under constraints

A route group gives structure.

Permission vocabulary gives precision.


11. Middleware: coarse gate, not fine-grained policy engine

Middleware can redirect anonymous users before expensive rendering.

Example:

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const sessionCookie = request.cookies.get('__Host-app_session')
  const pathname = request.nextUrl.pathname

  const requiresAuth = pathname.startsWith('/dashboard') || pathname.startsWith('/cases')

  if (requiresAuth && !sessionCookie) {
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('returnTo', pathname)
    return NextResponse.redirect(loginUrl)
  }

  return NextResponse.next()
}

This is only a coarse optimization.

Do not use Middleware as the only authorization layer for:

object ownership
case state transitions
field-level permission
tenant membership freshness
high-risk action step-up
role hierarchy
policy explanation
workflow constraints

Why?

Because Middleware often lacks the full resource context and may run in a different runtime with different data access constraints.

Use Middleware to:

redirect obviously anonymous traffic
apply coarse request headers
block obviously invalid tenants
normalize URLs
set correlation metadata

Use server functions/services/route handlers to:

perform exact authorization decisions
load resource state
write audit events
validate workflow invariants

12. Cache-control is part of auth

App Router has multiple caching layers.

Do not treat cache as a performance-only feature.

For authenticated data, ask:

Who is this cached for?
Can another user receive it?
Can it survive logout?
Can it survive permission revocation?
Can it survive tenant switch?
Can it leak through back/forward cache?
Can it leak through CDN/shared cache?

For sensitive Route Handler responses:

return NextResponse.json(payload, {
  headers: {
    'Cache-Control': 'no-store',
    'Vary': 'Cookie',
  },
})

For server-side data loading, avoid broad shared caching unless the data is public or explicitly tenant-safe.

For session projection:

no-store
Vary: Cookie
minimal payload
no raw provider claims
no tokens

For authorization-sensitive pages:

Prefer per-request session reads.
Tie query/cache keys to tenant/user/permission epoch.
Invalidate/revalidate after mutations.
Clear client caches on logout and tenant switch.

A fast leaked page is still a leaked page.


13. Session projection endpoint

Even with Server Components, Client Components often need a minimal auth projection.

Example:

// app/api/session/route.ts
import { NextResponse } from 'next/server'
import { readSession } from '@/auth/server-session'

export async function GET() {
  const session = await readSession()

  if (session.kind === 'anonymous') {
    return NextResponse.json(
      { kind: 'anonymous' },
      {
        headers: {
          'Cache-Control': 'no-store',
          'Vary': 'Cookie',
        },
      }
    )
  }

  return NextResponse.json(
    {
      kind: 'authenticated',
      user: {
        id: session.userId,
        displayName: session.displayName,
      },
      tenant: {
        id: session.tenantId,
        name: session.tenantName,
      },
      assuranceLevel: session.assuranceLevel,
      permissionEpoch: session.permissionEpoch,
    },
    {
      headers: {
        'Cache-Control': 'no-store',
        'Vary': 'Cookie',
      },
    }
  )
}

A good session endpoint answers:

Who is the current projected user?
Which tenant/org is active?
What is the auth/session state?
What is the permission version/epoch?
What is the minimum data the UI needs?

It should not answer:

What are all provider claims?
What is the access token?
What is the refresh token?
What are all internal policy traces?
What are all entitlements for all tenants?

14. Next.js auth with downstream APIs

There are two common patterns.

Pattern A — Server-side data access

Server Component / Route Handler / Server Action
  -> internal service
  -> database or downstream API

The browser never sees API tokens.

Pattern B — Browser calls BFF Route Handler

Client Component
  -> /api/cases/:id
  -> BFF Route Handler
  -> downstream API

The browser holds only an app session cookie.

Both patterns can be good.

Avoid Pattern C unless risk is low and the trade-off is explicit:

Client Component
  -> downstream API directly with browser-held access token

This pushes credential handling into the browser and reintroduces many SPA auth risks.

Decision matrix:

RequirementPrefer
High-risk regulated dataServer-side/BFF
Needs third-party API tokenServer-side token vault
Needs fast public dataPublic cache/CDN
Needs interactive mutationServer Action or BFF Route Handler
Needs realtime channelServer-issued short-lived channel ticket
Needs offline-first appExplicit offline auth design

15. Server Action vs Route Handler

Use Server Actions when:

mutation is tied to a form/action in the React tree
result is UI-state-shaped
progressive enhancement matters
same app owns the action

Use Route Handlers when:

endpoint is called by multiple clients
response must follow explicit HTTP contract
you need webhooks/callbacks
you need file upload/download flows
you need SSE/WebSocket alternatives
you need API-like testability

For authorization, both must do the same core work:

read session
validate CSRF if cookie-based mutation
validate input
authorize subject/action/resource/context
execute command
write audit event
return safe response
invalidate/revalidate cache

Do not let implementation surface change security semantics.


If you use cookies for session and accept state-changing requests, CSRF matters.

Mitigation layers:

SameSite cookie
Origin/Referer validation
CSRF token tied to session
content-type restrictions when useful
step-up for sensitive actions
idempotency keys for retry-sensitive mutations

Example CSRF assertion:

export async function assertCsrf(request: Request, session: AuthenticatedSession) {
  const origin = request.headers.get('origin')

  if (!isAllowedOrigin(origin)) {
    throw new ForbiddenError('invalid_origin')
  }

  const submitted = request.headers.get('x-csrf-token')
  const expected = await csrfStore.getForSession(session.sessionId)

  if (!submitted || submitted !== expected) {
    throw new ForbiddenError('invalid_csrf_token')
  }
}

Server Actions also need CSRF thinking. They are server-side functions, but the request that invokes them still originates from the browser.

The rule is simple:

Cookie-authenticated mutation needs CSRF defense.

17. Step-up authentication in App Router

Sensitive actions should not depend only on an old session.

Example sensitive actions:

change email
change password
invite admin
assign role
approve regulated case
export sensitive data
start impersonation
publish policy

Model the decision explicitly:

type AuthDecision =
  | { allowed: true }
  | { allowed: false; reason: 'forbidden' }
  | { allowed: false; reason: 'step_up_required'; maxAgeSeconds: number }

Server Action:

const decision = await policyEngine.check({
  subject: session.userId,
  action: 'role.assign',
  resource: { type: 'tenant', id: session.tenantId },
  context: {
    authTime: session.authTime,
    assuranceLevel: session.assuranceLevel,
  },
})

if (!decision.allowed && decision.reason === 'step_up_required') {
  return {
    ok: false,
    type: 'step_up_required',
    redirectTo: buildSafeStepUpUrl({ returnTo: '/admin/users' }),
  }
}

The UI receives a recovery path, not a bypass.


18. Tenant-aware App Router auth

Multi-tenant App Router auth needs one active tenant context.

Bad:

User is authenticated globally.
All tenant data queries trust tenantId from URL.

Better:

Session has activeTenantId.
URL tenant slug is resolved server-side.
Membership is checked server-side.
Resource queries are scoped by tenant.
Permission cache key includes tenant.
Client receives tenant projection only.

Example:

export async function requireTenantAccess(input: {
  session: AuthenticatedSession
  tenantSlug: string
}) {
  const tenant = await tenantRepository.findBySlug(input.tenantSlug)

  if (!tenant) {
    throw new NotFoundError('tenant_not_found')
  }

  const membership = await membershipRepository.find({
    tenantId: tenant.id,
    userId: input.session.userId,
  })

  if (!membership || membership.status !== 'active') {
    throw new NotFoundError('tenant_not_found')
  }

  return {
    tenantId: tenant.id,
    membershipId: membership.id,
  }
}

Returning 404 for unauthorized tenant can reduce enumeration. Internally, still log the denied attempt with enough metadata for investigation.


19. App Router auth error taxonomy

Use typed errors.

class UnauthorizedError extends Error {
  code = 'login_required' as const
}

class ForbiddenError extends Error {
  constructor(public reason: string) {
    super(reason)
  }
}

class StepUpRequiredError extends Error {
  code = 'step_up_required' as const
}

class TenantMismatchError extends Error {
  code = 'tenant_mismatch' as const
}

Map them deliberately:

ErrorHTTP/Page resultUser meaning
anonymous401 or redirect loginSign in required
expired session401 or refresh/reloginSession expired
forbidden403You do not have access
tenant mismatch404 or 403Resource not available
step-up required403 with typed reason or redirectRe-authenticate required
stale permission403 with refresh hintAccess changed

Do not collapse all auth failures into login redirect.

That creates loops and hides real permission bugs.


20. Error boundaries for App Router auth

Use route-level error boundaries to avoid leaking data and to provide useful recovery.

'use client'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <main>
      <h1>Something went wrong</h1>
      <p>We could not load this secure view.</p>
      <button onClick={() => reset()}>Try again</button>
    </main>
  )
}

But be careful: error boundary props are client-visible.

Do not pass:

raw policy trace
internal ACL graph
SQL query
provider response body
session id
access token

Prefer a public error DTO:

type PublicAuthError = {
  type: 'login_required' | 'forbidden' | 'step_up_required' | 'not_found'
  message: string
  correlationId: string
}

21. Middleware redirect loops

Common bad loop:

middleware sees no cookie -> redirect /login
/login layout reads session -> redirects /dashboard
/dashboard middleware sees stale cookie missing in edge context -> redirect /login

Loop prevention checklist:

Exclude /login, /callback, /logout, /api/auth/* from auth redirect middleware.
Use exact path matching.
Validate returnTo as internal path.
Represent session states explicitly.
Clear invalid cookies deliberately.
Avoid redirecting 403 to login.
Cap redirect attempts with a transaction id.
Log loop metrics.

Example matcher design:

export const config = {
  matcher: [
    '/dashboard/:path*',
    '/cases/:path*',
    '/admin/:path*',
  ],
}

Do not match everything and then try to patch exceptions forever.


22. Safe return URL policy

Login flows often need to return the user to the intended page.

Never trust raw URL input.

export function parseSafeReturnTo(value: string | null): string {
  if (!value) return '/dashboard'

  try {
    if (!value.startsWith('/')) return '/dashboard'
    if (value.startsWith('//')) return '/dashboard'

    const url = new URL(value, 'https://app.example.com')

    if (url.origin !== 'https://app.example.com') {
      return '/dashboard'
    }

    if (url.pathname.startsWith('/api/')) {
      return '/dashboard'
    }

    return `${url.pathname}${url.search}${url.hash}`
  } catch {
    return '/dashboard'
  }
}

The App Router return URL policy should handle:

login
logout
step-up auth
SSO callback
tenant discovery
invitation acceptance
password reset
MFA enrollment

Every one of those can become an open redirect if the return path is not constrained.


23. Auth provider callback in App Router

A callback Route Handler should be protocol code.

// app/api/auth/callback/route.ts
import { NextResponse } from 'next/server'
import { exchangeCodeForSession } from '@/auth/oauth'
import { parseSafeReturnTo } from '@/auth/return-to'

export async function GET(request: Request) {
  const url = new URL(request.url)
  const code = url.searchParams.get('code')
  const state = url.searchParams.get('state')

  if (!code || !state) {
    return NextResponse.redirect(new URL('/login?error=callback_failed', url.origin))
  }

  const result = await exchangeCodeForSession({ code, state })
  const returnTo = parseSafeReturnTo(result.returnTo)

  const response = NextResponse.redirect(new URL(returnTo, url.origin))

  response.cookies.set('__Host-app_session', result.sessionId, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    path: '/',
  })

  return response
}

Callback invariants:

state is required
PKCE verifier is bound to transaction
transaction is single-use
returnTo is internal-only
session cookie is HttpOnly/Secure
no tokens in redirect URL
no provider response in client-visible error

24. Authorization in Server Components vs services

You can authorize directly in a Server Component:

await requirePermission({
  session,
  action: 'case.view',
  resource: { type: 'case', id: caseId },
})

But for serious systems, prefer a service boundary:

export async function loadCasePage(input: {
  session: AuthenticatedSession
  caseId: string
}) {
  await requirePermission({
    session: input.session,
    action: 'case.view',
    resource: { type: 'case', id: input.caseId },
  })

  const record = await caseRepository.findForTenant({
    tenantId: input.session.tenantId,
    caseId: input.caseId,
  })

  return toCasePageDto(record)
}

Then the Server Component stays thin:

export default async function CasePage({ params }: Props) {
  const { caseId } = await params
  const session = await requireUser()
  const dto = await loadCasePage({ session, caseId })

  return <CasePageView caseRecord={dto} />
}

Why this is better:

Reusable from Route Handlers and Server Actions.
Easier to test.
Reduces missed authorization checks.
Centralizes audit.
Keeps component tree from becoming business layer.

25. App Router auth folder structure

One possible structure:

src/
  auth/
    server-session.ts
    cookies.ts
    csrf.ts
    return-to.ts
    errors.ts
    projection.ts
    provider/
      oauth-client.ts
      callback.ts
  policy/
    check.ts
    decision.ts
    actions.ts
  app/
    (public)/
      login/page.tsx
    (authenticated)/
      layout.tsx
      dashboard/page.tsx
      cases/[caseId]/page.tsx
    api/
      session/route.ts
      cases/[caseId]/route.ts

Boundary rule:

auth/server-* modules must not be imported by Client Components.

Enforce via lint/import conventions:

server-only modules stay server-only
client-safe projections stay in shared DTO modules
policy action vocabulary can be shared
policy evaluation stays server-side unless explicitly projection-only

26. Production checklist

Before shipping App Router auth, verify:

[ ] Session cookie is HttpOnly, Secure, scoped, and intentionally SameSite.
[ ] Session validation happens server-side.
[ ] Raw tokens are not passed to Client Components.
[ ] Server Components use safe DTO projections.
[ ] Route Handlers authorize per request.
[ ] Server Actions authorize per mutation.
[ ] Middleware is only coarse gate, not final authz.
[ ] Return URLs are internal-only.
[ ] OAuth callback validates state and PKCE.
[ ] Authenticated responses use correct cache-control.
[ ] Tenant membership is checked server-side.
[ ] Step-up auth exists for sensitive actions.
[ ] 401 and 403 are not collapsed into one behavior.
[ ] Logout clears server session, cookies, and client caches.
[ ] Error boundaries do not leak internal policy data.
[ ] Tests cover anonymous, expired, forbidden, stale permission, wrong tenant, and step-up required.

27. Common anti-patterns

Anti-pattern 1 — Auth only in layout

Authenticated layout exists.
Individual resources are not authorized.

Fix:

Layout checks session.
Page/service checks resource permission.
Mutation checks action permission.

Anti-pattern 2 — Passing full database row to Client Component

Fix:

Use DTO projection.
Expose only what UI needs.

Anti-pattern 3 — Middleware as policy engine

Fix:

Use Middleware for coarse routing.
Use server/service policy checks for exact decisions.

Anti-pattern 4 — Server Action trusts hidden field

Fix:

Treat formData as hostile.
Authorize from server-loaded session and server-loaded resource state.

Anti-pattern 5 — Caching authenticated data as if public

Fix:

no-store for sensitive responses.
Vary on Cookie when appropriate.
Invalidate on permission/session/tenant changes.

28. Final mental model

A strong App Router auth design says:

React Server Components reduce browser exposure.
They do not remove the need for authorization.

Route Handlers provide BFF/API boundaries.
They do not authorize automatically.

Server Actions run on the server.
They still receive hostile input.

Middleware can redirect early.
It rarely has enough context for fine-grained authorization.

Client Components make the UI interactive.
They must receive safe projections only.

The top 1% mental model:

Every App Router surface is an execution boundary.
Every execution boundary needs a security responsibility.
Every responsibility needs an invariant.
Every invariant needs a test.

References

Lesson Recap

You just completed lesson 73 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.