Authentication & Authorization in Next.js
Authentication and authorization are the gatekeepers of any real-world application. In Next.js, the decision of where to run auth logic โ inside a Server Component, in middleware, or on the client โ directly affects security, performance, and user experience. With the App Router and React Server Components, the safest place to check identity is on the server, where secrets never reach the browser and sessions can be verified before rendering starts.
Next.js does not ship with a built-in auth system. Instead, you compose one from primitives: the request/response objects in route handlers, the server-side cookies available in Server Components, middleware for lightweight route guards, and Server Actions for form submissions. Third-party libraries such as Auth.js and Clerk provide the actual identity plumbing, while Next.js provides the runtime and routing layer that wires them together.
This guide covers Auth.js v5 with the Next.js App Router, Clerk integration, route protection with middleware and role-based access, securing Server Actions, and choosing between JWT and database session strategies. The goal is a production-grade mental model: keep secrets on the server, verify every request, and minimize the attack surface of your application.
Auth.js v5, still often referred to as NextAuth, is the open-source authentication library for the web. It supports credentials, OAuth, email magic links, and passkeys. For Next.js 16 and the App Router, you install the next-auth package, create an auth.ts configuration file, and expose a catch-all route handler under /app/api/auth/[...nextauth]/route.ts.
Start with environment variables. Auth.js requires a AUTH_SECRET for token signing and session encryption. You can generate one with npx auth secret. OAuth providers need their client ID and client secret, and you should configure their callback URLs to point to /api/auth/callback/<provider>.
| 1 | # Install Auth.js v5 for Next.js |
| 2 | npm install next-auth@beta |
| 3 | |
| 4 | # Generate a strong AUTH_SECRET |
| 5 | npx auth secret |
| 6 | |
| 7 | # .env.local |
| 8 | AUTH_SECRET="your-generated-secret-min-32-chars" |
| 9 | AUTH_GOOGLE_ID="your-google-client-id" |
| 10 | AUTH_GOOGLE_SECRET="your-google-client-secret" |
| 11 | AUTH_GITHUB_ID="your-github-client-id" |
| 12 | AUTH_GITHUB_SECRET="your-github-client-secret" |
The central configuration lives in auth.ts. This file defines providers, session strategy, callbacks, and database adapter. In the App Router, you import the auth helper from this file to read the session in Server Components, and use SessionProvider in a client wrapper when you need session state on the client.
| 1 | // auth.ts |
| 2 | import NextAuth from "next-auth"; |
| 3 | import Credentials from "next-auth/providers/credentials"; |
| 4 | import Google from "next-auth/providers/google"; |
| 5 | import GitHub from "next-auth/providers/github"; |
| 6 | import { ZodError } from "zod"; |
| 7 | import { signInSchema } from "./lib/validation"; |
| 8 | |
| 9 | export const { handlers, auth, signIn, signOut } = NextAuth({ |
| 10 | providers: [ |
| 11 | GitHub, |
| 12 | Google, |
| 13 | Credentials({ |
| 14 | credentials: { |
| 15 | email: { label: "Email", type: "email" }, |
| 16 | password: { label: "Password", type: "password" }, |
| 17 | }, |
| 18 | authorize: async (credentials) => { |
| 19 | try { |
| 20 | const { email, password } = await signInSchema.parseAsync(credentials); |
| 21 | |
| 22 | // Replace with your real user lookup and password verification |
| 23 | const user = await getUserFromDb(email); |
| 24 | if (!user) return null; |
| 25 | |
| 26 | const valid = await verifyPassword(password, user.passwordHash); |
| 27 | if (!valid) return null; |
| 28 | |
| 29 | return { |
| 30 | id: user.id, |
| 31 | name: user.name, |
| 32 | email: user.email, |
| 33 | role: user.role, |
| 34 | }; |
| 35 | } catch (error) { |
| 36 | if (error instanceof ZodError) return null; |
| 37 | throw error; |
| 38 | } |
| 39 | }, |
| 40 | }), |
| 41 | ], |
| 42 | session: { |
| 43 | strategy: "jwt", |
| 44 | maxAge: 30 * 24 * 60 * 60, // 30 days |
| 45 | }, |
| 46 | pages: { |
| 47 | signIn: "/login", |
| 48 | error: "/login", |
| 49 | }, |
| 50 | callbacks: { |
| 51 | jwt({ token, user, trigger, session }) { |
| 52 | if (user) { |
| 53 | token.id = user.id; |
| 54 | token.role = user.role; |
| 55 | } |
| 56 | if (trigger === "update" && session?.name) { |
| 57 | token.name = session.name; |
| 58 | } |
| 59 | return token; |
| 60 | }, |
| 61 | session({ session, token }) { |
| 62 | session.user.id = token.id as string; |
| 63 | session.user.role = token.role as string; |
| 64 | return session; |
| 65 | }, |
| 66 | authorized({ auth, request: { nextUrl } }) { |
| 67 | const isLoggedIn = !!auth?.user; |
| 68 | const isProtected = nextUrl.pathname.startsWith("/dashboard"); |
| 69 | if (isProtected && !isLoggedIn) { |
| 70 | const redirectUrl = new URL("/login", nextUrl); |
| 71 | redirectUrl.searchParams.set("callbackUrl", nextUrl.pathname); |
| 72 | return Response.redirect(redirectUrl); |
| 73 | } |
| 74 | return true; |
| 75 | }, |
| 76 | }, |
| 77 | }); |
Expose the Auth.js HTTP API through a catch-all route handler. This single handler accepts sign-in, sign-out, callback, and session requests. The GET and POST methods are both delegated to the handlers exported from auth.ts.
| 1 | // app/api/auth/[...nextauth]/route.ts |
| 2 | import { handlers } from "@/auth"; |
| 3 | |
| 4 | export const { GET, POST } = handlers; |
Server Components can read the session directly with the auth helper. There is no need to wrap the app in a provider, which keeps the root layout server-rendered and avoids sending session data to the client unnecessarily.
| 1 | // app/page.tsx |
| 2 | import { auth } from "@/auth"; |
| 3 | import { redirect } from "next/navigation"; |
| 4 | |
| 5 | export default async function HomePage() { |
| 6 | const session = await auth(); |
| 7 | |
| 8 | if (!session) { |
| 9 | redirect("/login"); |
| 10 | } |
| 11 | |
| 12 | return ( |
| 13 | <main> |
| 14 | <h1>Welcome, {session.user.name}</h1> |
| 15 | <p>Role: {session.user.role}</p> |
| 16 | </main> |
| 17 | ); |
| 18 | } |
If you need session data on the client, for example to conditionally show UI after hydration, create a client component with the SessionProvider and use the useSession hook. Keep the provider as low in the tree as possible so that only the components that actually need it are wrapped.
| 1 | // components/AuthProvider.tsx |
| 2 | "use client"; |
| 3 | |
| 4 | import { SessionProvider } from "next-auth/react"; |
| 5 | import { ReactNode } from "react"; |
| 6 | |
| 7 | export default function AuthProvider({ children }: { children: ReactNode }) { |
| 8 | return <SessionProvider>{children}</SessionProvider>; |
| 9 | } |
| 10 | |
| 11 | // app/layout.tsx |
| 12 | import AuthProvider from "@/components/AuthProvider"; |
| 13 | |
| 14 | export default function RootLayout({ children }: { children: React.ReactNode }) { |
| 15 | return ( |
| 16 | <html lang="en"> |
| 17 | <body> |
| 18 | <AuthProvider>{children}</AuthProvider> |
| 19 | </body> |
| 20 | </html> |
| 21 | ); |
| 22 | } |
| 23 | |
| 24 | // components/UserStatus.tsx |
| 25 | "use client"; |
| 26 | |
| 27 | import { useSession, signIn, signOut } from "next-auth/react"; |
| 28 | |
| 29 | export default function UserStatus() { |
| 30 | const { data: session, status } = useSession(); |
| 31 | |
| 32 | if (status === "loading") return <p>Loading...</p>; |
| 33 | if (!session) return <button onClick={() => signIn()}>Sign in</button>; |
| 34 | |
| 35 | return ( |
| 36 | <button onClick={() => signOut()}> |
| 37 | Sign out {session.user?.email} |
| 38 | </button> |
| 39 | ); |
| 40 | } |
info
Clerk is a managed authentication platform that provides user management, organizations, roles, and UI components out of the box. It is a strong alternative to Auth.js when you want dashboards, user impersonation, and multi-organization support without building the backend infrastructure yourself.
Install the Clerk SDK, add your keys to .env.local, and wrap the root layout with ClerkProvider. Clerk uses its own middleware to manage session state, so you will also need a middleware.ts file at the project root.
| 1 | # Install Clerk for Next.js |
| 2 | npm install @clerk/nextjs |
| 3 | |
| 4 | # .env.local |
| 5 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="pk_test_..." |
| 6 | CLERK_SECRET_KEY="sk_test_..." |
| 7 | NEXT_PUBLIC_CLERK_SIGN_IN_URL="/sign-in" |
| 8 | NEXT_PUBLIC_CLERK_SIGN_UP_URL="/sign-up" |
| 9 | NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL="/dashboard" |
| 10 | NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL="/dashboard" |
| 1 | // app/layout.tsx |
| 2 | import { ClerkProvider } from "@clerk/nextjs"; |
| 3 | |
| 4 | export default function RootLayout({ children }: { children: React.ReactNode }) { |
| 5 | return ( |
| 6 | <ClerkProvider> |
| 7 | <html lang="en"> |
| 8 | <body>{children}</body> |
| 9 | </html> |
| 10 | </ClerkProvider> |
| 11 | ); |
| 12 | } |
| 13 | |
| 14 | // app/sign-in/[[...sign-in]]/page.tsx |
| 15 | import { SignIn } from "@clerk/nextjs"; |
| 16 | |
| 17 | export default function SignInPage() { |
| 18 | return <SignIn />; |
| 19 | } |
| 20 | |
| 21 | // app/sign-up/[[...sign-up]]/page.tsx |
| 22 | import { SignUp } from "@clerk/nextjs"; |
| 23 | |
| 24 | export default function SignUpPage() { |
| 25 | return <SignUp />; |
| 26 | } |
| 27 | |
| 28 | // components/Header.tsx |
| 29 | import { UserButton, SignedIn, SignedOut } from "@clerk/nextjs"; |
| 30 | import Link from "next/link"; |
| 31 | |
| 32 | export default function Header() { |
| 33 | return ( |
| 34 | <header className="flex items-center justify-between p-4"> |
| 35 | <Link href="/">Home</Link> |
| 36 | <SignedIn> |
| 37 | <UserButton /> |
| 38 | </SignedIn> |
| 39 | <SignedOut> |
| 40 | <Link href="/sign-in">Sign in</Link> |
| 41 | </SignedOut> |
| 42 | </header> |
| 43 | ); |
| 44 | } |
Clerk exposes an auth() helper for Server Components and route handlers. It returns an object with the user ID, session claims, and organization information. Use it to gate content, check roles, and personalize data fetching.
| 1 | // app/dashboard/page.tsx |
| 2 | import { auth, currentUser } from "@clerk/nextjs/server"; |
| 3 | import { redirect } from "next/navigation"; |
| 4 | |
| 5 | export default async function DashboardPage() { |
| 6 | const { userId, sessionClaims } = await auth(); |
| 7 | |
| 8 | if (!userId) { |
| 9 | redirect("/sign-in"); |
| 10 | } |
| 11 | |
| 12 | const user = await currentUser(); |
| 13 | const role = sessionClaims?.metadata?.role as string | undefined; |
| 14 | |
| 15 | return ( |
| 16 | <main> |
| 17 | <h1>Dashboard</h1> |
| 18 | <p>User: {user?.firstName}</p> |
| 19 | <p>Role: {role ?? "member"}</p> |
| 20 | </main> |
| 21 | ); |
| 22 | } |
| 23 | |
| 24 | // app/api/admin/route.ts |
| 25 | import { auth } from "@clerk/nextjs/server"; |
| 26 | import { NextResponse } from "next/server"; |
| 27 | |
| 28 | export async function GET() { |
| 29 | const { userId, sessionClaims } = await auth(); |
| 30 | |
| 31 | if (!userId) { |
| 32 | return new NextResponse("Unauthorized", { status: 401 }); |
| 33 | } |
| 34 | |
| 35 | const role = sessionClaims?.metadata?.role as string | undefined; |
| 36 | if (role !== "admin") { |
| 37 | return new NextResponse("Forbidden", { status: 403 }); |
| 38 | } |
| 39 | |
| 40 | return NextResponse.json({ secret: "admin-only data" }); |
| 41 | } |
The Clerk middleware is where you enforce global route protection. Use the createRouteMatcherhelper to define public routes, and protect everything else by default. This matches the "deny by default" security posture: every route requires a session unless explicitly listed as public.
| 1 | // middleware.ts |
| 2 | import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; |
| 3 | |
| 4 | const isPublicRoute = createRouteMatcher([ |
| 5 | "/", |
| 6 | "/sign-in(.*)", |
| 7 | "/sign-up(.*)", |
| 8 | "/api/webhook(.*)", |
| 9 | ]); |
| 10 | |
| 11 | export default clerkMiddleware(async (auth, req) => { |
| 12 | if (!isPublicRoute(req)) { |
| 13 | await auth.protect(); |
| 14 | } |
| 15 | }); |
| 16 | |
| 17 | export const config = { |
| 18 | matcher: [ |
| 19 | "/((?!_next/static|_next/image|favicon.ico|.*\.\w+$).*)", |
| 20 | ], |
| 21 | }; |
warning
Route protection in Next.js can happen at multiple layers. Middleware is ideal for coarse-grained access control: is the user signed in? Are they hitting an admin prefix? Server Components and route handlers are better for fine-grained decisions that depend on database state, such as ownership or subscription tier.
The middleware matcher is the most common source of protection bugs. A matcher that is too narrow leaves routes unprotected; a matcher that is too broad runs auth on every asset request, including static files and images. The standard Clerk matcher above excludes _next/static, _next/image, favicons, and files with explicit extensions.
| 1 | // middleware.ts with role-based route protection |
| 2 | import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; |
| 3 | |
| 4 | const isPublicRoute = createRouteMatcher([ |
| 5 | "/", |
| 6 | "/sign-in(.*)", |
| 7 | "/sign-up(.*)", |
| 8 | "/api/public(.*)", |
| 9 | ]); |
| 10 | |
| 11 | const isAdminRoute = createRouteMatcher(["/admin(.*)"]); |
| 12 | |
| 13 | export default clerkMiddleware(async (auth, req) => { |
| 14 | const { userId, sessionClaims } = await auth(); |
| 15 | |
| 16 | // Public routes need no further checks |
| 17 | if (isPublicRoute(req)) return; |
| 18 | |
| 19 | // Any non-public route requires a session |
| 20 | if (!userId) { |
| 21 | return new Response("Unauthorized", { status: 401 }); |
| 22 | } |
| 23 | |
| 24 | // Admin routes require the admin role stored in session metadata |
| 25 | if (isAdminRoute(req)) { |
| 26 | const role = sessionClaims?.metadata?.role as string | undefined; |
| 27 | if (role !== "admin") { |
| 28 | return new Response("Forbidden", { status: 403 }); |
| 29 | } |
| 30 | } |
| 31 | }); |
| 32 | |
| 33 | export const config = { |
| 34 | matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\.\w+$).*)"], |
| 35 | }; |
Redirecting to sign-in is usually better than returning a 401 for page routes. A 401 is correct for APIs, but a browser user hitting a protected page should be sent somewhere useful. Always preserve the original URL as a callback so the user returns after signing in.
| 1 | // middleware.ts with redirect to sign-in |
| 2 | import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; |
| 3 | import { NextResponse } from "next/server"; |
| 4 | |
| 5 | const isPublicRoute = createRouteMatcher(["/", "/sign-in(.*)", "/sign-up(.*)"]); |
| 6 | |
| 7 | export default clerkMiddleware(async (auth, req) => { |
| 8 | const { userId } = await auth(); |
| 9 | |
| 10 | if (!isPublicRoute(req) && !userId) { |
| 11 | const signInUrl = new URL("/sign-in", req.url); |
| 12 | signInUrl.searchParams.set("redirect_url", req.url); |
| 13 | return NextResponse.redirect(signInUrl); |
| 14 | } |
| 15 | }); |
| 16 | |
| 17 | export const config = { |
| 18 | matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\.\w+$).*)"], |
| 19 | }; |
In Server Components, you can check the role and ownership with database context. This is where you enforce rules like "only the project owner can delete it" or "only paid subscribers can access this feature." Keep the checks explicit and return early on failure.
| 1 | // app/projects/[id]/page.tsx |
| 2 | import { auth } from "@clerk/nextjs/server"; |
| 3 | import { notFound, redirect } from "next/navigation"; |
| 4 | import { getProject } from "@/lib/projects"; |
| 5 | |
| 6 | export default async function ProjectPage({ |
| 7 | params, |
| 8 | }: { |
| 9 | params: Promise<{ id: string }>; |
| 10 | }) { |
| 11 | const { userId } = await auth(); |
| 12 | if (!userId) redirect("/sign-in"); |
| 13 | |
| 14 | const { id } = await params; |
| 15 | const project = await getProject(id); |
| 16 | |
| 17 | if (!project) notFound(); |
| 18 | |
| 19 | // Ownership check |
| 20 | if (project.ownerId !== userId) { |
| 21 | return ( |
| 22 | <main> |
| 23 | <h1>Forbidden</h1> |
| 24 | <p>You do not have permission to view this project.</p> |
| 25 | </main> |
| 26 | ); |
| 27 | } |
| 28 | |
| 29 | return ( |
| 30 | <main> |
| 31 | <h1>{project.name}</h1> |
| 32 | <p>Owner: {project.ownerId}</p> |
| 33 | </main> |
| 34 | ); |
| 35 | } |
In Client Components, you can only check what the client already knows. The session data is derived from the server and exposed through a provider. Client-side guards are useful for UI affordances, but they must never be the only line of defense. Always re-verify permissions on the server.
| 1 | // components/AdminLink.tsx |
| 2 | "use client"; |
| 3 | |
| 4 | import { useAuth } from "@clerk/nextjs"; |
| 5 | import Link from "next/link"; |
| 6 | |
| 7 | export default function AdminLink() { |
| 8 | const { sessionClaims } = useAuth(); |
| 9 | const role = sessionClaims?.metadata?.role as string | undefined; |
| 10 | |
| 11 | if (role !== "admin") return null; |
| 12 | |
| 13 | return <Link href="/admin">Admin Dashboard</Link>; |
| 14 | } |
| 15 | |
| 16 | // app/dashboard/page.tsx with client and server guards |
| 17 | import { auth } from "@clerk/nextjs/server"; |
| 18 | import AdminLink from "@/components/AdminLink"; |
| 19 | |
| 20 | export default async function DashboardPage() { |
| 21 | const { userId } = await auth(); |
| 22 | if (!userId) return null; // middleware already redirects, but be defensive |
| 23 | |
| 24 | return ( |
| 25 | <main> |
| 26 | <h1>Dashboard</h1> |
| 27 | <AdminLink /> |
| 28 | </main> |
| 29 | ); |
| 30 | } |
best practice
Server Actions run on the server, so they have access to the same cookies and session data as route handlers and Server Components. The correct pattern is to call your auth helper inside the action and reject unauthorized requests immediately. This is where most form submissions are protected, because forms are the primary way users mutate state.
Auth.js actions import auth from auth.ts. Clerk actions import auth from @clerk/nextjs/server. The shape of the returned session differs, but the pattern is the same: verify, then act.
| 1 | // app/actions/post.ts |
| 2 | "use server"; |
| 3 | |
| 4 | import { auth } from "@/auth"; |
| 5 | import { revalidatePath } from "next/cache"; |
| 6 | import { createPostSchema } from "@/lib/validation"; |
| 7 | |
| 8 | export async function createPost(formData: FormData) { |
| 9 | const session = await auth(); |
| 10 | |
| 11 | if (!session?.user) { |
| 12 | throw new Error("Unauthorized"); |
| 13 | } |
| 14 | |
| 15 | const data = createPostSchema.parse({ |
| 16 | title: formData.get("title"), |
| 17 | content: formData.get("content"), |
| 18 | }); |
| 19 | |
| 20 | // Insert into database with the authenticated user as owner |
| 21 | await db.post.create({ |
| 22 | data: { |
| 23 | title: data.title, |
| 24 | content: data.content, |
| 25 | authorId: session.user.id, |
| 26 | }, |
| 27 | }); |
| 28 | |
| 29 | revalidatePath("/posts"); |
| 30 | } |
| 31 | |
| 32 | // app/posts/new/page.tsx |
| 33 | import { createPost } from "@/app/actions/post"; |
| 34 | |
| 35 | export default function NewPostPage() { |
| 36 | return ( |
| 37 | <form action={createPost}> |
| 38 | <input name="title" placeholder="Title" required /> |
| 39 | <textarea name="content" placeholder="Content" required /> |
| 40 | <button type="submit">Publish</button> |
| 41 | </form> |
| 42 | ); |
| 43 | } |
| 1 | // app/actions/clerk-post.ts |
| 2 | "use server"; |
| 3 | |
| 4 | import { auth } from "@clerk/nextjs/server"; |
| 5 | import { revalidatePath } from "next/cache"; |
| 6 | import { createPostSchema } from "@/lib/validation"; |
| 7 | |
| 8 | export async function createPost(formData: FormData) { |
| 9 | const { userId } = await auth(); |
| 10 | |
| 11 | if (!userId) { |
| 12 | throw new Error("Unauthorized"); |
| 13 | } |
| 14 | |
| 15 | const data = createPostSchema.parse({ |
| 16 | title: formData.get("title"), |
| 17 | content: formData.get("content"), |
| 18 | }); |
| 19 | |
| 20 | await db.post.create({ |
| 21 | data: { |
| 22 | title: data.title, |
| 23 | content: data.content, |
| 24 | authorId: userId, |
| 25 | }, |
| 26 | }); |
| 27 | |
| 28 | revalidatePath("/posts"); |
| 29 | } |
| 30 | |
| 31 | // Server Action with ownership check |
| 32 | export async function deletePost(postId: string) { |
| 33 | const { userId } = await auth(); |
| 34 | if (!userId) throw new Error("Unauthorized"); |
| 35 | |
| 36 | const post = await db.post.findUnique({ where: { id: postId } }); |
| 37 | if (!post) throw new Error("Post not found"); |
| 38 | if (post.authorId !== userId) throw new Error("Forbidden"); |
| 39 | |
| 40 | await db.post.delete({ where: { id: postId } }); |
| 41 | revalidatePath("/posts"); |
| 42 | } |
Server Actions are protected against cross-site request forgery by default because they require a same-origin request and a valid session. However, you should still validate and sanitize every input. Never trust the shape of data sent from a form, and never expose internal IDs or error messages that reveal the existence of resources.
danger
Auth.js supports two session strategies: JSON Web Tokens stored in an encrypted cookie, and database sessions stored in a persistence layer. Clerk manages session state internally and exposes it through short-lived tokens. The choice of strategy affects latency, revocation, and complexity.
| Aspect | JWT | Database Session |
|---|---|---|
| Storage | Encrypted cookie on the client | Session row in database |
| Latency | No DB hit to verify | Requires DB lookup |
| Revocation | Hard to revoke until token expires | Instant by deleting session row |
| Size limits | Limited by cookie size (4KB) | Only the session ID is in the cookie |
| Best for | Stateless, high-traffic APIs | Admin dashboards, sensitive operations |
| Scaling | Easy, no shared storage | Requires session store or database |
In Auth.js, choose the strategy with the session.strategy option. The JWT strategy is the default and works well for most applications. Switch to database sessions when you need to revoke sessions, track active logins, or store large amounts of session metadata.
| 1 | // auth.ts with database session adapter |
| 2 | import NextAuth from "next-auth"; |
| 3 | import Google from "next-auth/providers/google"; |
| 4 | import { PrismaAdapter } from "@auth/prisma-adapter"; |
| 5 | import { db } from "@/lib/db"; |
| 6 | |
| 7 | export const { handlers, auth, signIn, signOut } = NextAuth({ |
| 8 | adapter: PrismaAdapter(db), |
| 9 | providers: [Google], |
| 10 | session: { |
| 11 | strategy: "database", // Store session in the database |
| 12 | maxAge: 30 * 24 * 60 * 60, |
| 13 | updateAge: 24 * 60 * 60, |
| 14 | }, |
| 15 | callbacks: { |
| 16 | session({ session, user }) { |
| 17 | session.user.id = user.id; |
| 18 | return session; |
| 19 | }, |
| 20 | }, |
| 21 | }); |
Token lifecycle matters regardless of strategy. Keep access tokens short-lived, refresh tokens longer but rotatable, and session cookies scoped to your domain with the Secure, HttpOnly, and SameSite attributes set. Auth.js and Clerk set these defaults for you, but review them when you customize cookies.
info
1. Never expose secrets to the client. Keep provider secrets, API keys, and session signing keys in environment variables that are only read server-side.
2. Use environment variables through process.env in server code. Never import or reference secrets in client components, and do not prefix them with NEXT_PUBLIC_.
3. Validate all inputs on the server, including data from forms, query parameters, and request bodies. Zod is a solid choice for TypeScript schemas.
4. Use HTTPS in production so cookies and tokens cannot be intercepted in transit. Local development should use a trusted TLS proxy for production parity.
5. Keep tokens short-lived. An access token should last minutes, not days. Long-lived sessions can be refreshed securely with rotating refresh tokens.
6. Secure cookies with HttpOnly, Secure, and SameSite=Lax or Strict. Never store tokens in localStorage.
7. Use middleware for global, coarse-grained protection. Use Server Components and route handlers for fine-grained, database-backed authorization.
8. Run authorization checks as close to the data as possible. The further from the database the check is, the easier it is to bypass with a new code path.
9. Log authentication failures, suspicious IPs, and role escalation attempts. Security is not just prevention; it is also detection.
10. Rotate keys periodically. Auth.js and Clerk both support key rotation without invalidating active user sessions.
1. Calling auth on the client only. A client-side if (!user) redirect() check does not protect anything. The server must enforce the rule.
2. Storing secrets in client code. Anything in a browser bundle can be read by a user. Keep secrets in server-side code and environment variables.
3. Missing or incorrect middleware matcher. An overly broad matcher slows your app; a missing matcher leaves routes open. Test the matcher against your actual route list.
4. Race conditions in auth state. Client-side checks can run before the session has loaded. Use the loading state and always verify on the server before mutations.
5. Trusting roles from the client. Session claims or roles can be tampered with if sent from the client. Re-read the role from the server session on every request.
6. Using unencrypted cookies. If you set your own cookies, encrypt them. Auth.js and Clerk handle this, but custom solutions must be audited.
7. Forgetting CSRF protection for custom forms. Server Actions include CSRF protection by default, but custom API endpoints and external form submissions need explicit guards.
8. Leaking user existence through error messages. Use generic messages like "Invalid credentials" instead of "User not found" to prevent user enumeration.
9. Overloading JWT payloads. Cookies have a 4KB limit in most browsers. Keep JWT claims small and fetch heavy data from the database when needed.
10. Skipping input validation. Auth checks are not a substitute for validation. Malformed input can still crash your application or lead to injection attacks.
pro tip