Introduction: Why Authentication in Next.js Changed So Much
If you've tried adding authentication to a Next.js app over the past few years, you know the pain. You needed getServerSession for server-side checks, useSession for client components, a SessionProvider wrapping your layout, and separate middleware logic to redirect unauthenticated users. Honestly, the mental model was all over the place — one auth system, but multiple APIs depending on where you needed to check a session.
Auth.js v5 (formerly NextAuth.js) changes everything.
One function — auth() — now works everywhere: server components, route handlers, middleware (now proxy.ts in Next.js 16), and server actions. The migration to standard Web APIs means edge runtime compatibility out of the box. And because the App Router is server-first by nature, you can protect routes, fetch user data, and authorize actions all on the server without leaking sensitive logic to the browser.
So, let's walk through every layer of authentication in a modern Next.js application. We'll start with setting up Auth.js v5, cover OAuth and credentials providers, build protected routes using server components, implement role-based access control, and dig into the security patterns that actually matter in production. By the end, you'll have a solid mental model for authentication that takes full advantage of the App Router.
Setting Up Auth.js v5
First things first — install the dependencies. Auth.js v5 ships under the next-auth@5 package on npm:
npm install next-auth@5
If you're planning to use a database adapter (for persisting users, accounts, and sessions), grab the appropriate adapter package too. For example, with Prisma:
npm install @auth/prisma-adapter @prisma/client
The Configuration File
Auth.js v5 centralizes all configuration into a single auth.ts file at the root of your project. This file exports the functions you'll use throughout your entire application:
// auth.ts
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
],
pages: {
signIn: "/login",
error: "/auth/error",
},
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const isOnDashboard = nextUrl.pathname.startsWith("/dashboard");
if (isOnDashboard) {
if (isLoggedIn) return true;
return false; // redirect to login
}
return true;
},
},
});
That single export gives you everything. handlers sets up the OAuth callback routes, auth checks the current session, signIn triggers login, and signOut ends it. No more scattered config files — I can't overstate how much cleaner this is compared to the old approach.
The API Route Handler
Next, expose the OAuth callback endpoints through a catch-all route handler:
// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
That's literally the entire file. The handlers export contains the GET and POST handlers that Auth.js needs for the OAuth flow — handling provider callbacks, token exchanges, session creation, and CSRF protection, all automatically.
Environment Variables
Create a .env.local file with your provider credentials and an auth secret:
# .env.local
AUTH_SECRET=your-random-secret-at-least-32-chars
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
You can generate AUTH_SECRET with npx auth secret or openssl rand -base64 32. This secret encrypts JWT tokens and session cookies, so in production, keep it secure and never commit it to version control.
Understanding Providers
Auth.js v5 supports over 80 authentication providers. The two most common categories you'll work with are OAuth and credentials-based authentication.
OAuth Providers
OAuth providers like Google, GitHub, Discord, and Twitter handle identity verification for you. The user gets redirected to the provider's login page, authenticates there, and comes back with an access token. You don't store passwords, you don't manage email verification — the provider takes care of all that.
Each OAuth provider requires you to register your app and get a client ID and secret. The callback URL follows this pattern:
https://your-domain.com/api/auth/callback/google
https://your-domain.com/api/auth/callback/github
For local development, the callback URL is typically http://localhost:3000/api/auth/callback/[provider].
Credentials Provider
The credentials provider handles email/password authentication. It's more flexible but also requires more care on your end — you're responsible for password hashing, storage, and validation:
// auth.ts
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import { getUserByEmail } from "@/lib/db";
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await getUserByEmail(credentials.email as string);
if (!user || !user.hashedPassword) {
return null;
}
const isValid = await bcrypt.compare(
credentials.password as string,
user.hashedPassword
);
if (!isValid) return null;
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
};
},
}),
],
session: { strategy: "jwt" },
});
One critical thing to note: the credentials provider defaults to JWT sessions. If you want database sessions with credentials, you'll need to manually manage session creation in the signIn callback — it adds complexity but gives you more granular control over session management.
Session Management: JWT vs. Database
Auth.js v5 supports two session strategies, and understanding the trade-offs between them is pretty important for making the right call for your app.
JWT Strategy (Default)
With JWT sessions, the session data is encrypted and stored in a cookie. No database required. This is the default and works great for most applications:
// auth.ts
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [/* ... */],
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30 days
},
callbacks: {
async jwt({ token, user }) {
// First login: persist user data into the token
if (user) {
token.role = user.role;
token.id = user.id;
}
return token;
},
async session({ session, token }) {
// Make token data available on the session object
if (token) {
session.user.id = token.id as string;
session.user.role = token.role as string;
}
return session;
},
},
});
Advantages: No database round-trip for session validation, works at the edge, fast session resolution. Disadvantages: Session data is limited by cookie size (roughly 4KB), and you can't invalidate individual sessions from the server side without building additional infrastructure.
Database Strategy
Database sessions store everything server-side. The cookie only holds a session token that references the database record:
// auth.ts
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [/* ... */],
session: {
strategy: "database",
maxAge: 30 * 24 * 60 * 60, // 30 days
},
});
Advantages: Session data can be arbitrarily large, individual sessions can be revoked (great for "sign out all devices"), and you get a full audit trail. Disadvantages: Every session check hits the database, adding latency. It's also incompatible with the edge runtime, meaning you can't use database sessions in proxy.ts since it runs at the edge and can't make database calls.
When to Use Which
Go with JWT sessions if you need edge compatibility, have simple session data (user ID, role, name), and don't need server-side session invalidation. Choose database sessions if you need to store complex session data, require the ability to revoke sessions remotely, or want a complete audit trail. In my experience, most applications start with JWT and only move to database sessions when specific requirements demand it.
Protecting Routes in Server Components
This is where the App Router really shines. In server components, you can check the session directly — no client-side hooks, no context providers, no hydration mismatches:
// app/dashboard/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
return (
<div>
<h1>Welcome, {session.user.name}</h1>
<p>Your email: {session.user.email}</p>
<p>Your role: {session.user.role}</p>
</div>
);
}
The auth() call reads the session cookie, validates it, and returns the session object — all on the server. If the session is invalid or missing, you redirect right away. The browser never receives the protected content. This is fundamentally more secure than client-side auth checks, where protected content gets shipped to the browser and just hidden with JavaScript.
Protecting Layouts
You can protect routes at the layout level too, which stops an entire route segment from rendering without authentication:
// app/dashboard/layout.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
return (
<div className="dashboard-layout">
<nav>
<p>Signed in as {session.user.email}</p>
</nav>
<main>{children}</main>
</div>
);
}
A word of caution here: Next.js caches layout renders during navigation. That means the layout's auth() check might not run on every page transition within the same layout segment. For critical security checks, always validate at the page level or inside the data-fetching functions that retrieve sensitive content. Think of the layout check as an additional layer, not your only one.
Protecting API Route Handlers
Route handlers follow the exact same pattern:
// app/api/user/profile/route.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";
export async function GET() {
const session = await auth();
if (!session?.user) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
// Fetch user-specific data using session.user.id
const profile = await getUserProfile(session.user.id);
return NextResponse.json(profile);
}
Sign In and Sign Out with Server Actions
Auth.js v5 works seamlessly with Next.js server actions. You can build server-side functions for sign-in and sign-out that are called directly from form submissions — no client-side JavaScript required for the basic flow:
// app/actions/auth.ts
"use server";
import { signIn, signOut } from "@/auth";
import { AuthError } from "next-auth";
export async function loginWithGoogle() {
await signIn("google", { redirectTo: "/dashboard" });
}
export async function loginWithGitHub() {
await signIn("github", { redirectTo: "/dashboard" });
}
export async function loginWithCredentials(formData: FormData) {
try {
await signIn("credentials", {
email: formData.get("email") as string,
password: formData.get("password") as string,
redirectTo: "/dashboard",
});
} catch (error) {
if (error instanceof AuthError) {
switch (error.type) {
case "CredentialsSignin":
return { error: "Invalid email or password." };
default:
return { error: "Something went wrong." };
}
}
throw error; // re-throw non-auth errors
}
}
export async function logout() {
await signOut({ redirectTo: "/" });
}
Now build your login page using these server actions:
// app/login/page.tsx
import { loginWithGoogle, loginWithGitHub, loginWithCredentials } from "@/app/actions/auth";
export default function LoginPage() {
return (
<div>
<h1>Sign In</h1>
{/* OAuth Buttons */}
<form action={loginWithGoogle}>
<button type="submit">Sign in with Google</button>
</form>
<form action={loginWithGitHub}>
<button type="submit">Sign in with GitHub</button>
</form>
<hr />
{/* Credentials Form */}
<form action={loginWithCredentials}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" required />
<button type="submit">Sign In</button>
</form>
</div>
);
}
This login page works entirely without JavaScript — the forms submit server-side. But for a better user experience with loading states and error display, you can enhance it with a client component using useActionState (React 19) or useFormStatus:
// app/login/login-form.tsx
"use client";
import { useActionState } from "react";
import { loginWithCredentials } from "@/app/actions/auth";
export function LoginForm() {
const [state, formAction, isPending] = useActionState(
loginWithCredentials,
undefined
);
return (
<form action={formAction}>
{state?.error && (
<div className="error">{state.error}</div>
)}
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" required />
<button type="submit" disabled={isPending}>
{isPending ? "Signing in..." : "Sign In"}
</button>
</form>
);
}
The Proxy Layer: Lightweight Route Guarding
In Next.js 16, middleware.ts got renamed to proxy.ts. This rename makes the file's purpose clearer: it's a lightweight routing layer at the edge, not a place for heavy business logic. For authentication, the proxy should perform optimistic redirects based on cookie existence — not full session validation.
// proxy.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const { nextUrl } = req;
const isLoggedIn = !!req.auth;
const protectedPaths = ["/dashboard", "/settings", "/admin"];
const isProtected = protectedPaths.some((path) =>
nextUrl.pathname.startsWith(path)
);
if (isProtected && !isLoggedIn) {
const loginUrl = new URL("/login", nextUrl);
loginUrl.searchParams.set("callbackUrl", nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}
// Redirect logged-in users away from login page
if (nextUrl.pathname === "/login" && isLoggedIn) {
return NextResponse.redirect(new URL("/dashboard", nextUrl));
}
return NextResponse.next();
});
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*", "/admin/:path*", "/login"],
};
Here's the thing you really need to internalize: the proxy is not your security boundary. It gives users a smooth experience by redirecting them before they hit a protected page, but it should never be your only defense. The proxy runs at the edge with limited capabilities — it can't connect to databases, can't run Node.js-specific code, and shouldn't do expensive work. Always validate sessions again in your server components and route handlers.
What the Proxy Should and Should Not Do
Good use cases for the proxy:
- Redirecting unauthenticated users to the login page
- Redirecting authenticated users away from the login page
- Basic pathname-based route matching
- Setting response headers for security (CSP, CORS preflight)
What you should NOT do in the proxy:
- Full JWT validation with cryptographic verification
- Database queries for session or permission checks
- Complex authorization logic (RBAC checks, feature flags)
- Data fetching or external API calls
Role-Based Access Control (RBAC)
Most production apps need more than just "authenticated" vs. "not authenticated." You need roles — admin, editor, viewer — with different permission levels. Here's a clean RBAC pattern that works well with Auth.js v5 and the App Router.
Step 1: Extend the Session Types
First, add the role to your session type by creating a types/next-auth.d.ts file:
// types/next-auth.d.ts
import { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
role: "admin" | "editor" | "viewer";
} & DefaultSession["user"];
}
interface User {
role: "admin" | "editor" | "viewer";
}
}
declare module "next-auth/jwt" {
interface JWT {
role: "admin" | "editor" | "viewer";
}
}
Step 2: Pass the Role Through Callbacks
Configure the JWT and session callbacks to persist the role:
// auth.ts (callbacks section)
callbacks: {
async jwt({ token, user }) {
if (user) {
token.role = user.role;
token.id = user.id;
}
return token;
},
async session({ session, token }) {
session.user.id = token.id as string;
session.user.role = token.role;
return session;
},
},
Step 3: Create an Authorization Utility
Build a reusable function that handles both authentication and authorization checks:
// lib/authorization.ts
import { auth } from "@/auth";
import { redirect } from "next/navigation";
type Role = "admin" | "editor" | "viewer";
export async function requireAuth() {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
return session;
}
export async function requireRole(requiredRole: Role) {
const session = await requireAuth();
const roleHierarchy: Record<Role, number> = {
viewer: 1,
editor: 2,
admin: 3,
};
if (roleHierarchy[session.user.role] < roleHierarchy[requiredRole]) {
redirect("/unauthorized");
}
return session;
}
Step 4: Use in Pages and Components
// app/admin/page.tsx
import { requireRole } from "@/lib/authorization";
export default async function AdminPage() {
const session = await requireRole("admin");
return (
<div>
<h1>Admin Panel</h1>
<p>Welcome, {session.user.name} (role: {session.user.role})</p>
{/* Admin-only content */}
</div>
);
}
// app/editor/page.tsx
import { requireRole } from "@/lib/authorization";
export default async function EditorPage() {
const session = await requireRole("editor");
return (
<div>
<h1>Editor Dashboard</h1>
<p>Welcome, {session.user.name}</p>
{/* Editor and admin can access this */}
</div>
);
}
The role hierarchy is key here — it ensures that higher-privilege users (like admins) can automatically access lower-privilege pages too. This avoids the headache of duplicating permissions across roles.
Protecting Server Actions
Server actions are a common attack surface because they accept user input directly. Always validate the session before performing any mutation:
// app/actions/posts.ts
"use server";
import { auth } from "@/auth";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
const session = await auth();
if (!session?.user) {
throw new Error("Unauthorized");
}
const title = formData.get("title") as string;
const content = formData.get("content") as string;
// Validate input
if (!title || title.length < 3 || title.length > 200) {
return { error: "Title must be between 3 and 200 characters." };
}
// Create the post with the authenticated user's ID
await db.post.create({
data: {
title,
content,
authorId: session.user.id,
},
});
revalidatePath("/posts");
return { success: true };
}
export async function deletePost(postId: string) {
const session = await auth();
if (!session?.user) {
throw new Error("Unauthorized");
}
// Check ownership or admin role
const post = await db.post.findUnique({ where: { id: postId } });
if (!post) {
return { error: "Post not found." };
}
if (post.authorId !== session.user.id && session.user.role !== "admin") {
throw new Error("Forbidden");
}
await db.post.delete({ where: { id: postId } });
revalidatePath("/posts");
return { success: true };
}
Notice the pattern: check authentication first, then authorization (ownership or role), then validate input, and only then perform the mutation. This ordering matters — you want to fail fast and skip unnecessary database queries for unauthenticated or unauthorized requests.
Client-Side Session Access
While server-side auth is the way to go for security, client components sometimes need session data too — for conditionally rendering UI elements or personalizing the interface. Auth.js v5 provides the SessionProvider and useSession hook for exactly this:
// app/providers.tsx
"use client";
import { SessionProvider } from "next-auth/react";
export function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}
// app/layout.tsx
import { Providers } from "./providers";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
Then in your client components:
// components/user-menu.tsx
"use client";
import { useSession, signOut } from "next-auth/react";
export function UserMenu() {
const { data: session, status } = useSession();
if (status === "loading") {
return <div>Loading...</div>;
}
if (!session) {
return <a href="/login">Sign In</a>;
}
return (
<div>
<span>{session.user?.name}</span>
<button onClick={() => signOut()}>Sign Out</button>
</div>
);
}
This is important: never rely on client-side session checks for security. The useSession hook is for UI rendering only — actual access control must happen server-side. A determined user can modify client-side JavaScript to bypass any check you put there. Server components, route handlers, and server actions are where auth enforcement actually belongs.
Database Adapters: Persisting Users and Accounts
If you're using OAuth providers, you'll probably want to persist user data. Auth.js adapters handle the database schema and operations for storing users, accounts (provider links), sessions, and verification tokens. Here's a Prisma adapter setup:
// prisma/schema.prisma
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
role String @default("viewer")
accounts Account[]
sessions Session[]
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
Then wire up the adapter in your auth config:
// auth.ts
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [/* ... */],
session: { strategy: "jwt" }, // or "database"
callbacks: {
async jwt({ token, user }) {
if (user) {
token.role = user.role;
}
return token;
},
async session({ session, token }) {
session.user.role = token.role;
return session;
},
},
});
When a user signs in with Google for the first time, the adapter automatically creates a User record and an Account record linking them to their Google account. If the same email shows up with a different provider (say GitHub), Auth.js can link the accounts — though this behavior depends on your allowDangerousEmailAccountLinking setting, and you should think carefully about the security implications before enabling it.
Security Best Practices
Auth code is security-critical code. Here are the patterns and practices that'll keep your app safe in production.
1. Never Trust the Client
Every sensitive operation — data mutations, resource access, permission checks — must be validated on the server. Client-side checks are for UX, not security. Always call auth() in server components, route handlers, and server actions before doing anything privileged.
2. Use HTTPS Everywhere
Auth.js sets Secure cookies in production by default, which means they only travel over HTTPS. Make sure your production deployment enforces HTTPS. Vercel handles this automatically, but if you're self-hosting, you'll need explicit TLS configuration.
3. CSRF Protection
Auth.js v5 includes built-in CSRF protection for all authentication endpoints. The POST handler requires a valid CSRF token, managed automatically when you use the built-in sign-in/sign-out forms. When building custom forms, stick with server actions (which have their own CSRF protection) rather than direct API calls.
4. Rate Limiting
The credentials provider is vulnerable to brute-force attacks. You really should implement rate limiting on your login endpoint:
// lib/rate-limit.ts
const attempts = new Map<string, { count: number; lastAttempt: number }>();
export function checkRateLimit(key: string, maxAttempts = 5, windowMs = 15 * 60 * 1000) {
const now = Date.now();
const record = attempts.get(key);
if (!record || now - record.lastAttempt > windowMs) {
attempts.set(key, { count: 1, lastAttempt: now });
return { allowed: true, remaining: maxAttempts - 1 };
}
if (record.count >= maxAttempts) {
return { allowed: false, remaining: 0 };
}
record.count++;
record.lastAttempt = now;
return { allowed: true, remaining: maxAttempts - record.count };
}
For production, use a distributed rate limiter backed by Redis or something similar — the in-memory approach above won't work across multiple server instances.
5. Input Validation
Validate all inputs in your authorize function and server actions. A schema validation library like Zod makes this straightforward:
import { z } from "zod";
const loginSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
});
async authorize(credentials) {
const parsed = loginSchema.safeParse(credentials);
if (!parsed.success) {
return null;
}
// ... continue with validated data
}
6. Secure Cookie Configuration
Auth.js sets reasonable cookie defaults, but you can tighten them further:
export const { handlers, auth, signIn, signOut } = NextAuth({
// ...
cookies: {
sessionToken: {
name: "__Secure-authjs.session-token",
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: true,
},
},
},
});
7. Handle Token Refresh for OAuth
OAuth access tokens expire. If your app needs to call provider APIs on behalf of the user (e.g., accessing Google APIs), implement token refresh in the JWT callback:
callbacks: {
async jwt({ token, account }) {
// Initial sign in
if (account) {
return {
...token,
accessToken: account.access_token,
refreshToken: account.refresh_token,
expiresAt: account.expires_at,
};
}
// Return existing token if not expired
if (Date.now() < (token.expiresAt as number) * 1000) {
return token;
}
// Token expired — refresh it
try {
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
grant_type: "refresh_token",
refresh_token: token.refreshToken as string,
}),
});
const refreshed = await response.json();
return {
...token,
accessToken: refreshed.access_token,
expiresAt: Math.floor(Date.now() / 1000) + refreshed.expires_in,
refreshToken: refreshed.refresh_token ?? token.refreshToken,
};
} catch (error) {
return { ...token, error: "RefreshTokenError" };
}
},
},
The Full Authentication Architecture
Let's zoom out and see how all the layers fit together in a Next.js application with Auth.js v5:
- Proxy layer (
proxy.ts): Performs optimistic redirects based on cookie existence. Fast, edge-compatible, but not a security boundary. Keeps unauthenticated users away from protected pages and authenticated users away from the login page. - Server component layer: Calls
auth()to validate the session. This is your primary security boundary. Invalid sessions get redirected or rejected. Role-based checks happen here. - Server action layer: Every mutation validates the session before any write operation. Combines auth checks with input validation and authorization (ownership, role).
- Route handler layer: API endpoints call
auth()and return proper 401/403 HTTP responses for unauthorized requests. - Client component layer: Uses
useSessionfor UI rendering only. Never a security boundary. Shows or hides UI elements based on session state for a better experience.
This layered approach follows defense in depth — each layer adds protection, and no single layer carries all the weight. The proxy handles UX by preventing unnecessary page loads. Server components and actions deliver the real security guarantees. And client components provide a responsive, personalized interface.
Common Pitfalls and How to Avoid Them
Pitfall 1: Relying on Layout Checks Alone
Layout components in Next.js get cached during client-side navigation. If you only check authentication in a layout, a user whose session expires mid-browsing might still see protected content until they trigger a full page load. Always check authentication at the page or data-fetching level too.
Pitfall 2: Exposing Sensitive Data in JWT Tokens
JWT tokens are encrypted by Auth.js, but they live in cookies sent with every request. Keep payloads small — store only the user ID, role, and essential metadata. Fetch full user profiles from the database when you actually need them, not from the token.
Pitfall 3: Missing Error Handling in Server Actions
When signIn throws (wrong password, provider error), the error propagates to the client as a generic server error unless you catch it. Always wrap signIn calls in try/catch and return user-friendly messages. But don't reveal too much — "Invalid email or password" is better than "No account found with that email" (which tells an attacker whether an email is registered).
Pitfall 4: Not Setting AUTH_SECRET in Production
Auth.js requires the AUTH_SECRET environment variable in production. Without it, token encryption breaks and your app will either crash or produce insecure tokens. Double-check this variable is set in your deployment environment — it's an easy one to miss.
Pitfall 5: Using Database Sessions in the Proxy
The proxy (proxy.ts) runs at the edge and can't make database connections. If you're using database sessions, the proxy can only check whether a session cookie exists — it can't validate it against the database. Use JWT sessions if you need session data in the proxy, or keep the proxy's checks to cookie-existence-only.
Wrapping Up
Authentication in Next.js has come a long way with Auth.js v5 and the App Router. The unified auth() function gets rid of the fragmented API surface from earlier versions, and server components give you a naturally secure environment for session validation. Here are the key takeaways:
- Centralize your config in
auth.tsand exportauth,signIn,signOut, andhandlers - Use the proxy for UX-improving redirects, not as a security boundary
- Validate sessions in server components and server actions — that's your real security layer
- Implement RBAC with a role hierarchy utility you can call from any server context
- Keep JWT payloads small and handle OAuth token refresh
- Always validate input, rate-limit login attempts, and follow security best practices
With these patterns in place, you've got a production-ready authentication system that takes full advantage of the Next.js App Router while delivering the security guarantees your users expect.