Contents
32 Chapter

Auth and sessions — Auth.js v5 / OAuth / JWT

Choosing between JWT and DB session, Auth.js v5 setup, Credentials and OAuth providers, and patterns for reading the session in four places — RSC · Client · Server Action · middleware.

Chapter 31 covered performance measurement and improvement. This chapter looks at another first entry barrier of a fullstack app: auth and sessions. “Sign-up / login / role-gated pages” is something almost every real app runs into, and an area where a wrong design once leads to security incidents.

The output of this chapter is used directly in Chapter 34 (the fullstack Todo capstone). We also answer in this chapter how to wire permission checks inside the Server Actions seen in Chapter 27 (Server Actions and Forms), and what session model the storageState from Chapter 30 (Playwright) sits on.

Two branches in session models #

There are two main ways to build a session: JWT (JSON Web Token) and DB session.

JWT — stateless #

The server signs a token holding user info and hands it to the client. The client sends that token on every request, and the server verifies the signature to identify the user.

JWT flow
1. login succeeds → server issues a JWT (payload: { userId, exp })
2. client stores the JWT in a cookie or localStorage
3. JWT attached on every request → server verifies signature → extracts userId
4. no session store on the server (stateless)

Pros: no server-side session store needed, easy horizontal scaling.

Cons: forcibly invalidating a token is hard (wait for expiry, or maintain a deny list); larger tokens raise per-request cost.

DB session — stateful #

The server hands out only the session ID as a cookie, and stores actual user info in the DB.

DB session flow
1. login succeeds → server inserts a session row in the DB (id, userId, expires)
2. session id is sent to the client as a cookie
3. cookie attached on every request → server queries DB for the session → extracts userId
4. immediate invalidation possible by deleting the row on logout or forced expiry

Pros: immediate invalidation, session info can be extended freely.

Cons: a DB query per request (can be eased with caching), the overhead of operating a session store.

Choice criteria #

SituationRecommended model
Microservices, SSO across multiple domainsJWT
A single app where immediate logout is a mustDB session
OAuth only, never handle passwords yourselfeither is OK
A simple fullstack app (the capstone of this book)DB session (Auth.js v5 + Drizzle adapter recommended)

The examples in this chapter use DB session as the default. Immediate invalidation and session extension are both more frequently valuable during learning. If JWT is needed, you can switch within the same Auth.js by changing an option.

Auth.js v5 setup #

There are several auth libraries that fit Next.js 15 App Router, but the most universal pick is Auth.js v5 (formerly NextAuth.js).

install
pnpm add next-auth@beta

(Version pinning may shift depending on whether v5 is stable at the M5 mark. The examples in the chapter follow the v5 API shape.)

auth.ts — single entry point #

Auth.js v5 collects configuration and helpers in one file.

src/auth.ts
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Credentials from 'next-auth/providers/credentials';
import { DrizzleAdapter } from '@auth/drizzle-adapter';
import { db } from '@/lib/db';
import { verifyPassword } from '@/lib/password';

export const { auth, handlers, signIn, signOut } = NextAuth({
  adapter: DrizzleAdapter(db),
  session: { strategy: 'database' },  // use 'jwt' for JWT
  providers: [
    GitHub({
      clientId: process.env.AUTH_GITHUB_ID!,
      clientSecret: process.env.AUTH_GITHUB_SECRET!,
    }),
    Credentials({
      credentials: {
        email: { label: 'Email', type: 'email' },
        password: { label: 'Password', type: 'password' },
      },
      async authorize(credentials) {
        const user = await db.users.findByEmail(credentials.email as string);
        if (!user) return null;
        const ok = await verifyPassword(credentials.password as string, user.passwordHash);
        return ok ? { id: user.id, email: user.email, name: user.name } : null;
      },
    }),
  ],
  pages: {
    signIn: '/login',
  },
});

The essentials.

  • adapter connects the DB adapter (Drizzle / Prisma / Supabase, etc.).
  • session.strategy picks JWT / DB.
  • providers registers OAuth (GitHub / Google / …) and Credentials.
  • The returned auth / handlers / signIn / signOut are imported in other files.

API route handler #

src/app/api/auth/[...nextauth]/route.ts
export { GET, POST } from '@/auth';

(Auth.js v5’s handlers expose { GET, POST }. The line above exposes that as a route directly.)

Protected paths via middleware #

src/middleware.ts
import { auth } from '@/auth';
import { NextResponse } from 'next/server';

export default auth(req => {
  const isLoggedIn = !!req.auth;
  const isProtected = req.nextUrl.pathname.startsWith('/dashboard');

  if (isProtected && !isLoggedIn) {
    return NextResponse.redirect(new URL('/login', req.nextUrl));
  }
});

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

auth also covers the middleware signature. The current session arrives on req.auth.

Credentials provider — handling passwords yourself #

If OAuth alone is sufficient, not handling passwords is the safest stance from a security standpoint. If the app needs its own sign-up, password handling becomes necessary.

Hashing — argon2 or bcrypt #

Plain-text storage is never acceptable. Passwords are converted to a one-way hash before storage.

src/lib/password.ts
import argon2 from 'argon2';

export async function hashPassword(password: string): Promise<string> {
  return argon2.hash(password, {
    type: argon2.argon2id,
    memoryCost: 64 * 1024,
    timeCost: 3,
    parallelism: 1,
  });
}

export async function verifyPassword(password: string, hash: string): Promise<boolean> {
  try {
    return await argon2.verify(hash, password);
  } catch {
    return false;
  }
}

argon2id is currently OWASP’s recommendation. bcrypt is still a legitimate choice, but for a new project, prefer argon2.

The hash cost parameters trade off security against performance. Too light is weak against brute force; too heavy makes login slow. The settings above are a recommended starting point that costs roughly ~100ms on a general-purpose server.

“Cases where you should not handle passwords yourself” #

In the following cases, going OAuth-only dramatically reduces risk.

  • Apps with a small user count covered by one or two OAuth providers
  • Teams without the bandwidth to operate password-reset / recovery flows themselves
  • Cases where you cannot afford to implement extra security like 2FA yourself

OAuth lets GitHub / Google handle passwords themselves; we only receive the user identification. Most password-related incidents are outsourced.

OAuth provider — a GitHub example #

Registering a GitHub OAuth App #

  1. GitHub Settings → Developer settings → OAuth Apps → New OAuth App
  2. Application name: the app name (for example, modern-react-demo)
  3. Homepage URL: http://localhost:3000 for development; the real domain for production
  4. Authorization callback URL: http://localhost:3000/api/auth/callback/github
  5. After registration, you receive the Client ID and Client Secret.

Environment variables #

.env.local
AUTH_SECRET=<output of openssl rand -base64 32>
AUTH_GITHUB_ID=<Client ID>
AUTH_GITHUB_SECRET=<Client Secret>

AUTH_SECRET is used for JWT signing and session encryption. Never put it in code in production. Putting it in the environment-variable system covered in Chapter 33 (Deploy and Observability) is the standard.

Login button #

src/app/login/page.tsx (Server Component)
import { signIn } from '@/auth';

export default function LoginPage() {
  return (
    <main style={{ padding: '24px' }}>
      <h1>Login</h1>
      <form action={async () => {
        'use server';
        await signIn('github', { redirectTo: '/dashboard' });
      }}>
        <button type="submit">Log in with GitHub</button>
      </form>
    </main>
  );
}

signIn fits naturally with the Server Action pattern from Chapter 27. Calling it via server action inside a form ensures progressive enhancement.

Reading the session in four places #

What matters is where and how the same session info is read. App Router has four such places.

1. Server Component — call auth() directly #

src/app/dashboard/page.tsx (Server Component)
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

export default async function DashboardPage() {
  const session = await auth();
  if (!session?.user) redirect('/login');

  return (
    <main style={{ padding: '24px' }}>
      <h1>Dashboard</h1>
      <p>Hello, {session.user.name}.</p>
    </main>
  );
}

Await auth() directly inside a Server Component. If not authenticated, redirect. A common pattern is to split work between middleware for broad route protection and Server Components for fine-grained permission checks.

2. Client Component — <SessionProvider> + useSession #

To use a session in a Client Component, place SessionProvider at the root and use the useSession hook.

src/app/providers.tsx
'use client';

import { SessionProvider } from 'next-auth/react';
import type { ReactNode } from 'react';

export function Providers({ children }: { children: ReactNode }) {
  return <SessionProvider>{children}</SessionProvider>;
}
src/app/layout.tsx
import { Providers } from './providers';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body><Providers>{children}</Providers></body>
    </html>
  );
}
reading the session in a Client Component
'use client';

import { useSession } from 'next-auth/react';

export default function UserBadge() {
  const { data: session, status } = useSession();
  if (status === 'loading') return <span>...</span>;
  if (!session?.user) return <a href="/login">Login</a>;
  return <span>{session.user.name}</span>;
}

Caution: in a Client Component the session arrives asynchronously, so checking status together prevents flicker.

3. Server Action — auth() + permission check #

src/app/todos/actions.ts
'use server';

import { auth } from '@/auth';
import { db } from '@/lib/db';

export async function deleteTodo(id: string) {
  const session = await auth();
  if (!session?.user) throw new Error('Login required');

  const todo = await db.todos.findById(id);
  if (!todo) throw new Error('Item not found');
  if (todo.ownerId !== session.user.id) throw new Error('Forbidden');

  await db.todos.delete(id);
}

The permission check inside a Server Action is the last line of defense. Even if a caller bypasses the client UI and calls the RPC directly, missing permission checks expose data. Middleware is route-level protection; the check inside a Server Action is data-level protection. Both levels are required.

4. Middleware — route protection #

Let us look at the middleware.ts from earlier again.

src/middleware.ts (reproduced)
import { auth } from '@/auth';
import { NextResponse } from 'next/server';

export default auth(req => {
  const isLoggedIn = !!req.auth;
  const isProtected = req.nextUrl.pathname.startsWith('/dashboard');

  if (isProtected && !isLoggedIn) {
    return NextResponse.redirect(new URL('/login', req.nextUrl));
  }
});

Middleware is the layer that intercepts before a route renders. It blocks entry to a protected route itself and sends to the login page. There is a render-cost saving as well.

Permission model — start small #

Most apps are fine with the following two patterns at first.

Role-based — one enum column #

users table schema
export const users = pgTable('users', {
  id: text('id').primaryKey(),
  email: text('email').notNull().unique(),
  name: text('name'),
  role: text('role', { enum: ['user', 'admin'] }).notNull().default('user'),
});
permission check
const session = await auth();
if (session?.user.role !== 'admin') redirect('/');

Complex RBAC systems (Cerbos / Casbin, etc.) have value in large organizations, but in a small app a single enum column like this is clearer and has lower operational overhead.

Row-level — owner_id check #

The pattern where each user can only touch their own data. The same shape as the deleteTodo Server Action above.

row-level permission check
if (todo.ownerId !== session.user.id) throw new Error('Forbidden');

Most SaaS apps run with just these two patterns (role + row-level). Complex permission schemes can wait until they are truly needed.

Common traps #

1. Permission check only on the client #

🚫 client-only permission check
'use client';

export default function AdminButton() {
  const { data: session } = useSession();
  if (session?.user.role !== 'admin') return null;
  return <button onClick={() => deleteEverything()}>Delete all</button>;
}

Hiding the UI is good, but the button being invisible does not block the action. The deleteEverything Server Action must check again inside. Client permission checks are UX; Server Action permission checks are security.

2. Leaking passwords into logs #

🚫 password ends up in logs
console.log('login attempt:', credentials);  // includes password

Without reviewing logs alongside PII (personally identifiable information), incidents follow. Never log passwords or tokens. Chapter 33’s Sentry setup also specifies fields that are stripped automatically.

3. Exposing AUTH_SECRET #

🚫 AUTH_SECRET written into code
const AUTH_SECRET = 'my-secret-key';  // never do this

If AUTH_SECRET leaks, anyone can forge a valid JWT. Always via environment variables, and in production only via a secrets management system.

4. Secure / HttpOnly / SameSite on cookies #

Auth.js v5 sets cookie options to safe defaults, but if you have code that handles cookies directly, double-check the following options consciously.

  • HttpOnly: true — JS cannot access the cookie (XSS defense)
  • Secure: true — only sent over HTTPS
  • SameSite: 'lax' or 'strict' — CSRF defense

Try it yourself — a small protected page #

Build the following flow yourself.

  1. Auth.js setup: copy the auth.ts above as is and register a GitHub OAuth App. Put env vars in .env.local.
  2. Login page + dashboard: build /login (GitHub login via server action) and /dashboard (a Server Component that checks the session with auth()).
  3. middleware: protect /dashboard with middleware so an unauthenticated user typing the URL is sent to /login.
  4. Server Action permission check: add a “delete my data” Server Action to the dashboard and put a row-level check that only allows session.user.id to delete their own data.
  5. Verify with Playwright: use the storageState pattern from Chapter 30 to automate two scenarios — logged in and not.

Walk through the five steps and auth + permissions + middleware + Server Action + E2E line up as one flow.

Exercises #

  1. Practice choosing JWT vs DB session. Answer which model fits each of the following three apps and why. (a) A small single-domain blog + comments, (b) a SaaS where multiple microservices share the same user, (c) a payment app where “immediately block malicious accounts” is a security requirement. Refer to the choice criteria table in the chapter.
  2. Mapping permission checks to four places. For the following five requirements, answer in which of the four places (middleware / Server Component / Client Component / Server Action) the check belongs. (a) Blocking unauthenticated users from /dashboard, (b) hiding a menu item only admins should see, (c) only allowing users to delete their own Todo, (d) only allowing admins to deactivate another user’s account, (e) showing the logged-in user’s name in the header.
  3. Extending session info. Suppose you need to add organizationId to the session’s user object in addition to role. Refer to the chapter and the official docs, and answer in which location of Auth.js v5 (callbacks in auth.ts? the adapter? a Server Action?) that logic most naturally belongs.

In one line: auth comes in two models — JWT (stateless) and DB session (stateful) — and small apps usually start with a DB session. Auth.js v5 bundles OAuth / Credentials providers and adapters at the single entry point auth.ts, and in App Router the session is read in four places — middleware (route protection), Server Component (auth() await), Client Component (useSession), Server Action (auth() + row-level check). The UI hides on the client, but the last line of security defense is the permission check inside the Server Action. Passwords are hashed with argon2id, and AUTH_SECRET never sits in code.

Next chapter #

The next chapter, Chapter 33 Deploy and Observability, is the last chapter of Part 5. We line up the tools for putting the app you built into production and seeing what happens afterward. The choice criteria for Vercel and Cloudflare Pages, environment variables and secrets management, error tracking with Sentry, product analytics with PostHog. The answer to where this chapter’s AUTH_SECRET lives also arrives there.

X