Handbooks / Next.js / Chapter 7
Authentication Patterns
7 pages · ~5 min✓ Reviewed
Builds on Styling and Client State. Next up: Testing and Monitoring.
Sessions vs JWT Cookies
1. The Auth Flow
- Authentication proves who you are
- Authorization decides what you may do
- Credentials
- Create session
- Store in cookie
- Verifyevery request
2. DB Session vs JWT Cookie
| DB session | JWT cookie | |
|---|---|---|
| Cookie holds | opaque session ID | signed token: userId, expiresAt |
| Verify | DB lookup each request | signature check, no DB call |
| Revoke | instant | hard before expiry |
| Cost | slower, more storage | fast, scalable |
| Tip | short-lived, small payload, no secrets |
3. Cookie Flags
typescript
(await cookies()).set('session', token, { httpOnly: true, secure: true, sameSite: 'lax', path: '/', expires, })
in a Server Action or Route Handler
httpOnly: no JS accesssecure: HTTPS onlysameSite=lax: CSRF mitigationexpiresmatches the session
Common mistakes
A JWT in localStorage is readable by XSS. A JWT is signed, not hidden: no secrets in an unencrypted payload.
Protecting Routes in Middleware
1. Role of Middleware
- Runs before the request completes
- The fast first gate that redirects unauthenticated users
- Newer Next.js may name the file
proxy.ts - Check the version docs in
node_modules/next/dist/docs
2. The Redirect Pattern
- Read cookiereq.cookies
- Verifydecrypt / JWT
- Protected path?req.nextUrl.pathname
- Redirect/login
typescript
const protectedRoutes = ['/dashboard'] export async function middleware(req: NextRequest) { const path = req.nextUrl.pathname const session = await decrypt(req.cookies.get('session')?.value) if (protectedRoutes.includes(path) && !session) return NextResponse.redirect(new URL('/login', req.nextUrl)) return NextResponse.next() }
- Keep
protectedRoutesandpublicRoutesarrays - Logged-in user on
/login→ redirect to the dashboard
3. Matcher and Limits
Auth in Server Components and Actions
1. Server Components
- Cannot set cookies or redirect once streaming starts
- Read the session:
await cookies()(async in recent versions) - Missing session:
redirect('/login')fromnext/navigation
- Layouts do not re-render on client navigation
- A layout-only check can be skipped
- Check in every page or in the data fetch itself
2. Server Actions
- Actions are public POST endpoints
- Anyone can call them with
fetch, form or no form - Verify session and authorization inside every action
javascript
async function deletePost(id) { 'use server' const session = await verifySession() if (!session) throw new Error('Unauthorized') // check ownership or role // then mutate }
the check lives in the action, not the form
3. Inputs
The Data Access Layer Pattern
1. What a DAL Is
- DAL = one server-only module for every DB read and write
- Each function does its own auth + authorization check first
import 'server-only'on top: Client Component import fails the build
2. How It Works
cache()dedupes cookie decrypt + DB lookup to once per render pass
typescript
export const verifySession = cache(async () => { const session = await getSession() if (!session) redirect('/login') return session })
call it from every DAL function
- Return DTOs: only fields the caller may see
- Admin gets the full row; others get
{ name } - passwordHash and email never reach client props
3. Why a DAL
| Scattered checks |
|---|
Auth.js Basics and Why Middleware Is Not Enough
1. Auth.js Setup
typescript
// auth.ts export const { handlers, auth, signIn, signOut } = NextAuth({ providers: [GitHub], callbacks: { jwt, session, authorized } }) // app/api/auth/[...nextauth]/route.ts export const { GET, POST } = handlers
| Provider | Session |
|---|---|
| OAuth (GitHub, Google) | JWT or database |
| Credentials | JWT only (forced) |
| Adapter (Prisma, Drizzle) | session: { strategy: 'database' } |
await auth()reads the session on the serversignIn('github')andsignOut()run in Server Actions- Set AUTH_SECRET in the env
jwtthensessioncallbacks copyidorroleonto token and sessionauthorizedcallback can gate middleware
2. Middleware Is Not Enough
Auth Cheat Sheet
1. Session and Middleware
| DB session | JWT cookie | |
|---|---|---|
| Revoke | instantly | hard before expiry |
| Cost | DB hit | no DB call |
- Flags: httpOnly + secure + sameSite=lax
- Middleware: optimistic cookie check, redirect to
/login matcherskips_nextand static files- No DB calls in middleware
2. Verify in the Data Layer
- Components, Server Actions, Route Handlers verify the session themselves
- Actions are public endpoints
- DAL:
server-only, authorize in each function, return DTOs, not rows
typescript
import 'server-only' export const verifySession = cache(async () => { // no session: redirect('/login') })
Check yourself
Middleware redirects logged-out users away from /dashboard. A deletePost(id) Server Action has no session check. Can a logged-out user delete a post?
- Yes. Actions are public POST endpoints, so they can be called directly.
- Middleware is only an optimistic, path-based gate.
- Fix: call
verifySession()and check ownership inside the action.
An admin bans a user. With a 7-day JWT cookie versus a database session, who is locked out on the next request?
- Database session: locked out at once, because the row is deleted.
- JWT cookie: still valid until expiry, because verification is only a signature check.
- So keep JWTs short-lived.
The auth check sits only in layout.tsx. A user client-navigates between two pages under it. What is the bug?
- Layouts do not re-render on client navigation, so the check can be skipped.
- Check in every page or in the data fetch.
- Best: put it in the DAL function.
const post = await getPost(id)