Authentication Patterns in Next.js
NextAuth.js, JWT, Sessions, Middleware, and OAuth
LinkedIn Hook
"How do you protect this route from unauthorized users?"
That single interview question has sunk more candidates than any algorithm problem. Because authentication in Next.js isn't one thing — it's a web of decisions across middleware, server components, API routes, server actions, and OAuth providers.
Most developers can set up a login form. Far fewer can explain why they chose JWT over sessions, where the auth check should live, and how to protect every attack surface in a Next.js application.
In Lesson 6.3, I break down every authentication pattern you need to know — from the bouncer analogy that makes it click to production-ready code for middleware guards, server action protection, and the complete OAuth flow.
Read the full lesson -> [link]
#NextJS #Authentication #WebSecurity #JWT #OAuth #NextAuth #InterviewPrep #FullStack
What You'll Learn
- The fundamental difference between JWT-based and session-based authentication and when to choose each
- How to set up Auth.js (NextAuth.js v5) with providers, callbacks, and session management
- How middleware-based auth guards work and why they run at the edge before your page even loads
- How to protect server components, server actions, and API routes with concrete code patterns
- The complete OAuth authorization flow inside a Next.js application
- A mental model that maps every auth concept to a building security system
The Building Security Analogy — Layers of Protection
Authentication in Next.js is not a single checkpoint. It is a layered security system, like protecting a high-rise office building. Understanding the layers is the key to understanding where each auth pattern fits.
The Front Gate (Middleware) — Before anyone enters the building, they pass through the front gate. A guard checks their ID badge. If they don't have one, they're redirected to the registration desk. This is Next.js middleware: it intercepts every request before it reaches any page or API route. It runs at the edge, so it's the fastest possible checkpoint.
The Lobby Receptionist (Server Components) — Once inside, visitors reach the lobby. The receptionist checks their badge again and determines what floor they can access. This is server-side auth checking in server components: you verify the session and render different content (or redirect) based on the user's identity and role.
The Office Door (API Routes) — Each office has its own lock. Even if someone got past the lobby, they can't enter a specific office without the right key. This is API route protection: every endpoint independently verifies the caller's authorization before returning data.
The Filing Cabinet (Server Actions) — Inside the office, sensitive filing cabinets have their own locks. Even authorized employees can't access every document. This is server action protection: each mutation independently checks that the caller has permission to perform that specific operation.
The Badge Itself (JWT vs Session) — The badge technology matters. A JWT is like a self-contained smart card: it carries all your permissions encoded on the card itself, so guards can verify it without calling headquarters. A session token is like a simple ID number: guards must call the central database to look up what you're allowed to do. Each has trade-offs.
+-------------------------------------------------------------------+
| THE BUILDING SECURITY MODEL |
+-------------------------------------------------------------------+
| |
| REQUEST ARRIVES |
| | |
| v |
| [ MIDDLEWARE ] -- Front gate guard -- Redirect if no badge |
| | |
| v |
| [ SERVER COMPONENT ] -- Lobby receptionist -- Check role/session |
| | |
| v |
| [ API ROUTE ] -- Office door -- Verify per-endpoint access |
| | |
| v |
| [ SERVER ACTION ] -- Filing cabinet -- Validate per-mutation |
| |
| Badge type: JWT (self-contained) vs Session (database lookup) |
| |
+-------------------------------------------------------------------+
Napkin AI Visual Prompt: "Dark gradient (#0a0e1a -> #111827). Vertical flow diagram with four layers stacked top to bottom. Top: green (#10b981) gate icon labeled 'Middleware'. Second: purple (#8b5cf6) desk icon labeled 'Server Components'. Third: green lock icon labeled 'API Routes'. Bottom: purple cabinet icon labeled 'Server Actions'. Arrows flowing downward between each. On the right side, two badge icons: one labeled 'JWT' (green) and one labeled 'Session' (purple). White monospace labels throughout. Title: 'Layered Auth in Next.js'."
JWT vs Session-Based Authentication — The Core Trade-Off
This is the question interviewers love because it has no single right answer. The correct response demonstrates that you understand trade-offs, not just definitions.
How JWT Works
A JSON Web Token is a self-contained, cryptographically signed string. When a user logs in, the server creates a JWT containing the user's identity and any claims (role, permissions), signs it with a secret key, and sends it to the client. On subsequent requests, the client sends the JWT back, and the server verifies the signature without touching any database.
JWT Flow:
1. User sends credentials --> Server validates --> Server creates signed JWT
2. Server sends JWT to client --> Client stores in httpOnly cookie
3. Client sends JWT with every request --> Server verifies signature --> Grants access
(No database call needed)
How Session-Based Auth Works
When a user logs in, the server creates a session record in a database (or in-memory store like Redis) and sends the client a session ID — a random string with no meaning on its own. On subsequent requests, the client sends the session ID, and the server looks it up in the database to find the associated user.
Session Flow:
1. User sends credentials --> Server validates --> Server creates session in DB
2. Server sends session ID cookie to client
3. Client sends session ID with every request --> Server queries DB --> Grants access
(Database call required every time)
The Comparison
+---------------------+-----------------------------+------------------------------+
| Dimension | JWT | Session-Based |
+---------------------+-----------------------------+------------------------------+
| Storage | Token stored on client | Session data stored on |
| | (cookie or header) | server (DB/Redis) |
+---------------------+-----------------------------+------------------------------+
| Server state | Stateless (nothing stored | Stateful (server must |
| | on server) | maintain session store) |
+---------------------+-----------------------------+------------------------------+
| Scalability | Excellent (any server can | Harder (sessions must be |
| | verify without shared state)| shared across servers or |
| | | use sticky sessions) |
+---------------------+-----------------------------+------------------------------+
| Revocation | Hard (token valid until | Easy (delete session from |
| | expiry, need denylist) | database, immediate logout) |
+---------------------+-----------------------------+------------------------------+
| Payload size | Larger (carries claims | Tiny (just a session ID |
| | in every request) | string) |
+---------------------+-----------------------------+------------------------------+
| Security surface | Token theft = full access | Session ID theft = access |
| | until expiry. Must use | but server can invalidate. |
| | short expiry + refresh | Easier to revoke. |
| | tokens. | |
+---------------------+-----------------------------+------------------------------+
| Database dependency | None for verification | Required for every request |
+---------------------+-----------------------------+------------------------------+
| Best for | Microservices, distributed | Monoliths, apps needing |
| | systems, edge computing | instant revocation, banking |
+---------------------+-----------------------------+------------------------------+
| Next.js fit | Works well with middleware | Works well with Auth.js |
| | (edge can verify JWT) | database adapters |
+---------------------+-----------------------------+------------------------------+
The interview-ready answer: "I choose JWT for stateless, distributed systems where horizontal scaling matters and I can tolerate short token lifetimes. I choose sessions for applications where instant revocation is critical — like banking apps or admin panels where a compromised account must be locked out immediately. In Next.js specifically, Auth.js supports both strategies, but session-based with a database adapter is the default because it gives you revocation safety with minimal configuration."
Auth.js (NextAuth.js v5) Setup — The Standard
Auth.js is the de facto authentication library for Next.js. Version 5 (the current standard) integrates deeply with the App Router, server components, and middleware.
Installation and Base Configuration
# Install Auth.js and a database adapter
npm install next-auth@beta @auth/prisma-adapter
// auth.ts — Root-level auth configuration file
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import Google from "next-auth/providers/google";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
export const { handlers, auth, signIn, signOut } = NextAuth({
// Database adapter for session persistence
adapter: PrismaAdapter(prisma),
// Authentication providers
providers: [
// OAuth providers — redirect-based flow
GitHub({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
}),
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
// Credentials provider — email/password login
Credentials({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
// Find user in database
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user || !user.hashedPassword) {
throw new Error("Invalid credentials");
}
// Verify password against stored hash
const isValid = await bcrypt.compare(
credentials.password as string,
user.hashedPassword
);
if (!isValid) {
throw new Error("Invalid credentials");
}
// Return user object — this becomes the session user
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
};
},
}),
],
// Session configuration
session: {
strategy: "jwt", // Use "database" for session-based auth
maxAge: 30 * 24 * 60 * 60, // 30 days
},
// Callbacks to customize token and session data
callbacks: {
// Add custom fields to the JWT token
async jwt({ token, user }) {
if (user) {
token.role = user.role;
token.id = user.id;
}
return token;
},
// Make custom fields available in the session object
async session({ session, token }) {
if (session.user) {
session.user.role = token.role as string;
session.user.id = token.id as string;
}
return session;
},
// Control which users can sign in
async signIn({ user, account }) {
// Example: only allow verified email accounts
if (account?.provider === "credentials" && !user.email) {
return false;
}
return true;
},
},
// Custom pages (optional)
pages: {
signIn: "/login",
error: "/auth/error",
},
});
// app/api/auth/[...nextauth]/route.ts — Auth.js API route handler
import { handlers } from "@/auth";
// Export GET and POST handlers for all /api/auth/* routes
// This handles sign-in, sign-out, callback, and session endpoints
export const { GET, POST } = handlers;
Why This Architecture Matters
Auth.js exports four things from the root config: handlers (API routes), auth (session getter), signIn (trigger login), and signOut (trigger logout). This single source of truth means every part of your application — middleware, server components, API routes, server actions — uses the same auth() function to check the session. No duplicate logic, no inconsistencies.
Middleware-Based Auth — The Front Gate
Middleware runs before any route loads. It operates at the edge, which means it executes in milliseconds and can redirect unauthenticated users before any server component or API route even begins rendering.
// middleware.ts — Root-level middleware file
import { auth } from "@/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const { pathname } = req.nextUrl;
const isLoggedIn = !!req.auth;
// Define route categories
const publicRoutes = ["/", "/about", "/pricing", "/blog"];
const authRoutes = ["/login", "/register", "/auth/error"];
const protectedPrefixes = ["/dashboard", "/settings", "/admin"];
const adminRoutes = ["/admin"];
// Rule 1: Redirect logged-in users away from auth pages
// (No reason to show login page to authenticated users)
if (isLoggedIn && authRoutes.includes(pathname)) {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
}
// Rule 2: Redirect unauthenticated users away from protected pages
if (
!isLoggedIn &&
protectedPrefixes.some((prefix) => pathname.startsWith(prefix))
) {
// Preserve the intended destination so we can redirect after login
const callbackUrl = encodeURIComponent(pathname);
return NextResponse.redirect(
new URL(`/login?callbackUrl=${callbackUrl}`, req.nextUrl)
);
}
// Rule 3: Check role-based access for admin routes
if (adminRoutes.some((route) => pathname.startsWith(route))) {
const userRole = req.auth?.user?.role;
if (userRole !== "admin") {
return NextResponse.redirect(new URL("/unauthorized", req.nextUrl));
}
}
// Rule 4: Allow all other requests to proceed
return NextResponse.next();
});
// Configure which routes the middleware applies to
// This matcher excludes static files, images, and the auth API routes
export const config = {
matcher: [
"/((?!api/auth|_next/static|_next/image|favicon.ico|public).*)",
],
};
Why middleware is the first line of defense: Without middleware, an unauthenticated user's request reaches the server component, which must then check auth and redirect. That wastes server compute. Middleware stops unauthorized requests at the edge — before any rendering happens. Think of it as the building's front gate: cheaper and faster than letting someone walk all the way to the office door and then escorting them out.
Caveat: Middleware should not be your only auth check. It's a performance optimization and UX improvement, not a security guarantee. Always verify auth again at the data access layer (server components, API routes, server actions).
Protecting Server Components
Server components run exclusively on the server. They can directly call auth() to check the session and conditionally render content or redirect.
// app/dashboard/page.tsx — Protected server component
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
// Get the current session on the server
const session = await auth();
// If no session exists, redirect to login
if (!session?.user) {
redirect("/login");
}
// Session exists — render the protected page
return (
<main>
<h1>Welcome, {session.user.name}</h1>
<p>Role: {session.user.role}</p>
<DashboardContent userId={session.user.id} />
</main>
);
}
// components/role-gate.tsx — Reusable role-based access component
import { auth } from "@/auth";
interface RoleGateProps {
allowedRoles: string[];
children: React.ReactNode;
fallback?: React.ReactNode;
}
export default async function RoleGate({
allowedRoles,
children,
fallback = null,
}: RoleGateProps) {
const session = await auth();
// Check if the user's role is in the allowed list
if (!session?.user?.role || !allowedRoles.includes(session.user.role)) {
return fallback;
}
return <>{children}</>;
}
// Usage in a page:
// <RoleGate allowedRoles={["admin", "editor"]}>
// <AdminPanel />
// </RoleGate>
Protecting Server Actions
Server actions are server-side functions invoked from the client. Because the client can call any exported server action directly (it's essentially an API endpoint under the hood), every server action must independently verify authentication. Never trust that the caller went through a protected page first.
// app/actions/post-actions.ts — Protected server actions
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
// CREATE — only authenticated users can create posts
export async function createPost(formData: FormData) {
// Step 1: Verify authentication
const session = await auth();
if (!session?.user?.id) {
throw new Error("Unauthorized: You must be logged in to create a post.");
}
// Step 2: Extract and validate input
const title = formData.get("title") as string;
const content = formData.get("content") as string;
if (!title || !content) {
throw new Error("Title and content are required.");
}
// Step 3: Perform the mutation
const post = await prisma.post.create({
data: {
title,
content,
authorId: session.user.id, // Use session user ID, never trust client
},
});
// Step 4: Revalidate cached pages
revalidatePath("/dashboard/posts");
return { success: true, postId: post.id };
}
// DELETE — only the post author or admins can delete
export async function deletePost(postId: string) {
const session = await auth();
if (!session?.user?.id) {
throw new Error("Unauthorized: You must be logged in.");
}
// Authorization check: verify ownership or admin role
const post = await prisma.post.findUnique({
where: { id: postId },
select: { authorId: true },
});
if (!post) {
throw new Error("Post not found.");
}
const isOwner = post.authorId === session.user.id;
const isAdmin = session.user.role === "admin";
if (!isOwner && !isAdmin) {
throw new Error("Forbidden: You do not have permission to delete this post.");
}
await prisma.post.delete({ where: { id: postId } });
revalidatePath("/dashboard/posts");
return { success: true };
}
The critical principle: Authentication checks in server actions are not optional or redundant with middleware. A malicious user can craft HTTP requests directly to a server action endpoint, bypassing your UI entirely. The server action is the last line of defense — the filing cabinet lock in our building analogy.
Protecting API Routes
API routes in the App Router (Route Handlers) are standalone endpoints. Like server actions, they must independently verify authentication.
// app/api/posts/route.ts — Protected API route
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { NextRequest, NextResponse } from "next/server";
// GET — return posts for the authenticated user
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
const posts = await prisma.post.findMany({
where: { authorId: session.user.id },
orderBy: { createdAt: "desc" },
});
return NextResponse.json(posts);
}
// POST — create a new post (authenticated + validated)
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
// Role-based access: only editors and admins can create via API
if (!["editor", "admin"].includes(session.user.role)) {
return NextResponse.json(
{ error: "Forbidden: insufficient permissions" },
{ status: 403 }
);
}
const body = await request.json();
if (!body.title || !body.content) {
return NextResponse.json(
{ error: "Title and content are required" },
{ status: 400 }
);
}
const post = await prisma.post.create({
data: {
title: body.title,
content: body.content,
authorId: session.user.id,
},
});
return NextResponse.json(post, { status: 201 });
}
Pattern: 401 vs 403. Return 401 Unauthorized when the user is not authenticated (no session). Return 403 Forbidden when the user is authenticated but lacks permission. This distinction matters in interviews — it shows you understand the HTTP specification.
The OAuth Flow in Next.js — Step by Step
OAuth is the protocol behind "Sign in with Google/GitHub/etc." It's a redirect-based flow where your application never sees the user's password. Understanding this flow end-to-end is a common advanced interview topic.
+-------------------------------------------------------------------+
| OAUTH 2.0 AUTHORIZATION CODE FLOW |
| (with Next.js + Auth.js) |
+-------------------------------------------------------------------+
| |
| 1. User clicks "Sign in with GitHub" |
| Browser --> /api/auth/signin/github |
| |
| 2. Auth.js redirects to GitHub's authorization server |
| Browser --> github.com/login/oauth/authorize |
| (with client_id, redirect_uri, scope, state) |
| |
| 3. User authenticates with GitHub and approves access |
| (User enters GitHub username/password on GitHub's page) |
| |
| 4. GitHub redirects back to your callback URL with an auth code |
| Browser --> your-app.com/api/auth/callback/github |
| (with code and state parameters) |
| |
| 5. Auth.js exchanges the code for an access token |
| Your server --> GitHub API (server-to-server, not visible |
| to browser) |
| |
| 6. Auth.js uses the access token to fetch user profile |
| Your server --> GitHub API /user endpoint |
| |
| 7. Auth.js creates/updates user in your database |
| (via the Prisma adapter) |
| |
| 8. Auth.js creates a session (JWT or database session) |
| and sets an httpOnly cookie |
| |
| 9. User is redirected to the dashboard (or callbackUrl) |
| Browser --> /dashboard (now authenticated) |
| |
+-------------------------------------------------------------------+
The Sign-In Component
// components/auth/sign-in-buttons.tsx — OAuth sign-in buttons
"use client";
import { signIn } from "next-auth/react";
export function SignInButtons() {
return (
<div className="flex flex-col gap-4">
<button
onClick={() => signIn("github", { callbackUrl: "/dashboard" })}
className="flex items-center gap-2 rounded-lg bg-gray-800 px-6 py-3 text-white"
>
Sign in with GitHub
</button>
<button
onClick={() => signIn("google", { callbackUrl: "/dashboard" })}
className="flex items-center gap-2 rounded-lg bg-white px-6 py-3 text-gray-800 border"
>
Sign in with Google
</button>
</div>
);
}
// components/auth/sign-out-button.tsx — Sign-out button
"use client";
import { signOut } from "next-auth/react";
export function SignOutButton() {
return (
<button
onClick={() => signOut({ callbackUrl: "/" })}
className="rounded-lg bg-red-600 px-4 py-2 text-white"
>
Sign Out
</button>
);
}
// app/layout.tsx — Session provider wrapper
import { SessionProvider } from "next-auth/react";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{/* SessionProvider enables useSession() in client components */}
<SessionProvider>{children}</SessionProvider>
</body>
</html>
);
}
Key security points about OAuth:
- Your app never sees the user's GitHub/Google password. The user authenticates directly with the provider.
- The authorization code is exchanged server-to-server (step 5), so the access token is never exposed to the browser.
- The
stateparameter prevents CSRF attacks by ensuring the callback was initiated by your application. - Always use
httpOnlycookies for session storage — JavaScript cannot access them, preventing XSS token theft.
Napkin AI Visual Prompt: "Dark gradient (#0a0e1a -> #111827). Horizontal flow diagram showing 9 numbered steps of OAuth. Left: user icon (white). Center: your app box (green #10b981 border). Right: GitHub/Google icon (purple #8b5cf6). Arrows showing: user -> app -> provider -> user approves -> app callback -> server exchange -> database -> session cookie -> dashboard. Each step numbered in small green circles. White monospace labels. Title: 'OAuth Flow in Next.js'."
Common Mistakes
1. Relying only on middleware for auth protection.
Middleware is a UX layer, not a security layer. It prevents unnecessary page loads for unauthenticated users, but a determined attacker can bypass UI flows and call server actions or API routes directly. Always verify authentication at every data access point — server components, server actions, and API routes must each independently call auth().
2. Storing JWTs in localStorage instead of httpOnly cookies.
LocalStorage is accessible to any JavaScript on the page. If an XSS vulnerability exists (and in complex apps, they eventually do), an attacker can steal the token. The httpOnly cookie flag makes the token invisible to JavaScript — the browser sends it automatically, but no script can read it. Auth.js uses httpOnly cookies by default.
3. Not distinguishing authentication from authorization.
Authentication answers "who are you?" Authorization answers "what can you do?" Many candidates conflate them. Checking if (!session) is authentication. Checking if (session.user.role !== 'admin') is authorization. Both must be present in protected endpoints. An authenticated user is not automatically an authorized user.
4. Forgetting to protect server actions.
Because server actions are defined in files with "use server", developers assume they're somehow internal. They are not. Each server action becomes an HTTP endpoint that anyone can call with the right payload. Treat every server action as a public API endpoint and validate both authentication and authorization inside it.
5. Not handling token expiry and refresh.
JWTs expire. If you don't implement a refresh token strategy, users get silently logged out and see confusing errors. Auth.js handles session refresh automatically when using the database strategy, but with JWT strategy, you should implement the jwt callback to check token expiry and refresh from your provider when needed.
Interview Questions
1. "Explain the difference between authentication and authorization. How do you implement both in Next.js?"
Authentication verifies identity — "who is this user?" In Next.js, I implement this with Auth.js by calling auth() in server components, middleware, API routes, and server actions to check if a valid session exists. Authorization determines permissions — "what can this user do?" I implement this by checking the user's role or permissions from the session object. For example, after confirming the session exists (authentication), I check session.user.role === 'admin' before allowing access to admin operations (authorization). The key principle is that both checks happen at every layer: middleware for early redirection, server components for UI gating, and server actions/API routes for data-level enforcement.
2. "Why would you choose JWT over session-based auth in a Next.js application, or vice versa?"
I'd choose JWT when the application is distributed across multiple services or edge locations, because any server can verify the token independently without hitting a shared database — this matters for Next.js middleware which runs at the edge. I'd choose session-based auth when instant revocation is critical, like banking or admin applications, because I can delete the session from the database and the user is immediately locked out. With JWT, the token remains valid until it expires, and maintaining a denylist adds the same database dependency that sessions already have. In practice, Auth.js's default JWT strategy with short expiry times works well for most Next.js apps, and I'd switch to database sessions only when the business requires immediate invalidation.
3. "Walk me through what happens when a user clicks 'Sign in with Google' in your Next.js app."
The client calls signIn('google'), which redirects the browser to /api/auth/signin/google. Auth.js constructs a URL to Google's authorization server with the client ID, redirect URI, requested scopes, and a CSRF state token. The browser redirects to Google where the user authenticates and approves the requested permissions. Google redirects back to /api/auth/callback/google with an authorization code. Auth.js, on the server side, exchanges this code for an access token via a server-to-server request — the browser never sees the access token. Auth.js then uses that access token to fetch the user's profile from Google's API, creates or updates the user in the database via the Prisma adapter, creates a session (JWT or database-backed), sets an httpOnly cookie, and redirects the user to the dashboard. The entire flow ensures the user's Google password is never exposed to our application.
4. "A security auditor tells you that protecting routes only in middleware is insufficient. Why are they right?"
They're right because middleware is a request-level filter, not a data-level security boundary. Middleware intercepts page navigations and can redirect unauthenticated users, but server actions and API routes can be called directly via HTTP POST requests without ever triggering a page navigation. An attacker can use tools like curl or Postman to call /api/posts or invoke a server action endpoint directly, completely bypassing middleware. The defense-in-depth principle requires that every layer independently verifies auth: middleware for UX optimization, server components for rendering control, and API routes and server actions for data-level security. If any single layer is the sole guardian, a bypass at that layer compromises everything.
5. "How would you implement role-based access control (RBAC) across a Next.js application?"
I'd store the user's role in the database and include it in the session via Auth.js callbacks — the jwt callback adds the role to the token, and the session callback exposes it in session.user.role. In middleware, I check the role for route-level guards: admin routes redirect non-admin users. In server components, I use a reusable RoleGate component that checks session.user.role and conditionally renders children or a fallback. In server actions and API routes, every function checks the role before performing operations — a deleteUser action requires admin role regardless of which page called it. For complex permission systems, I'd introduce a permissions table and check specific permissions rather than roles, but for most apps, role-based checks at each layer provide sufficient granularity.
Quick Reference — Authentication Patterns Cheat Sheet
| Pattern | Where | Purpose | Key Code |
|---|---|---|---|
| Middleware Auth | middleware.ts | Early redirect, edge-level gate | auth((req) => { if (!req.auth) redirect }) |
| Server Component Auth | page.tsx | Conditional rendering, server redirect | const session = await auth() |
| Server Action Auth | "use server" functions | Per-mutation verification | const session = await auth() inside each action |
| API Route Auth | route.ts handlers | Per-endpoint verification, 401/403 | const session = await auth(), return status codes |
| OAuth Sign-In | Client component | Trigger provider redirect | signIn("github", { callbackUrl }) |
| Role-Based Gate | Reusable component | Render based on user role | if (!allowedRoles.includes(role)) return fallback |
| JWT Strategy | auth.ts config | Stateless, edge-compatible sessions | session: { strategy: "jwt" } |
| Database Sessions | auth.ts config | Revocable, server-stored sessions | session: { strategy: "database" } + adapter |
+----------------------------------------------------------+
| AUTH CHECK DECISION TREE |
+----------------------------------------------------------+
| |
| Is this a page navigation? |
| YES --> Middleware checks first (fast redirect) |
| THEN server component checks (render logic) |
| |
| Is this a data mutation? |
| Server Action --> auth() inside the action |
| API Route --> auth() inside the handler |
| |
| Is this role-restricted? |
| YES --> Check session.user.role at EVERY layer |
| |
| Rule: NEVER trust a previous layer's check. |
| Every layer verifies independently. |
| |
+----------------------------------------------------------+
Prev: Lesson 6.2 -- API Design in Next.js Next: Lesson 7.1 -- Image Optimization ->
This is Lesson 6.3 of the Next.js Interview Prep Course -- 8 chapters, 33 lessons.