Authentication is one of those things that’s easy to get wrong in ways that aren’t immediately obvious. I’ve implemented auth in Next.js projects ranging from simple JWTs to full OAuth 2.0 flows with refresh token rotation, and each time I’ve learned something new about the trade-offs. This post is the guide I wish I’d had: clear explanations of the options, concrete code for each pattern, and opinionated guidance on when to use what.
JWT vs Sessions: The Core Trade-Off
This is the foundational decision, and it’s worth understanding deeply before reaching for a library.
JWT (JSON Web Tokens) are self-contained. The token itself carries the user’s claims (user ID, roles, expiry). The server can verify a JWT without any database lookup — it just validates the signature. This makes JWTs attractive for stateless APIs and microservices.
The downside is revocation. If you issue a JWT with a 1-hour expiry and the user’s account gets compromised, you can’t invalidate that JWT before it expires. You’d need a token blocklist (a database of revoked tokens), which reintroduces server-side state and partially defeats the purpose.
Session-based auth stores the session server-side (usually in a database or Redis). The client gets a session ID cookie. Every request looks up the session in the server store. This is stateful, but revocation is instant — delete the session record and the user is logged out immediately.
My rule of thumb:
- JWT: Stateless APIs, short-lived tokens (minutes), microservices communication
- Sessions: Full-stack web apps, user dashboards, anything requiring instant revocation
For most Next.js applications I build, sessions win. The “stateless” benefit of JWT is rarely worth the revocation complexity for a web app with a database already in the stack.
Implementing Sessions with NextAuth
NextAuth (Auth.js) is my go-to for Next.js authentication. It handles the session lifecycle, CSRF protection, and OAuth flows out of the box.
// app/api/auth/[...nextauth]/route.ts
import NextAuth from "next-auth"
import GoogleProvider from "next-auth/providers/google"
import { DrizzleAdapter } from "@auth/drizzle-adapter"
import { db } from "@/lib/db"
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: DrizzleAdapter(db),
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!
})
],
session: {
strategy: "database", // Store sessions in DB, not JWT
maxAge: 30 * 24 * 60 * 60, // 30 days
},
callbacks: {
session({ session, user }) {
// Add custom fields to session
session.user.id = user.id
session.user.role = user.role
return session
}
}
})
With strategy: "database", NextAuth stores sessions in your database via the adapter. Signing out a user server-side is a single database delete. The session ID is stored in a __Secure-next-auth.session-token cookie with HttpOnly, Secure, and SameSite=Lax attributes — handled automatically.
OAuth 2.0 Flow: What Actually Happens
Understanding the OAuth 2.0 flow helps debug the inevitable issues. Here’s what happens when a user clicks “Sign in with Google”:
- Your app redirects to Google’s authorization endpoint with
client_id,redirect_uri,scope, and astateparameter (CSRF protection) - The user authenticates with Google and approves your app’s requested scopes
- Google redirects back to your
redirect_uriwith an authorizationcode - Your server exchanges the code for tokens at Google’s token endpoint (server-to-server, never exposed to browser)
- Google returns an
access_token(short-lived),id_token(user info JWT), and optionally arefresh_token - Your app creates a session for the authenticated user
NextAuth handles all of this. But knowing the flow helps when OAuth is misconfigured (wrong redirect URI, missing scopes) or when you need to implement it manually for a custom provider.
Secure Cookie Configuration
If you’re implementing auth without NextAuth, cookie security deserves explicit attention:
// Secure cookie settings
import { cookies } from "next/headers"
import { SignJWT, jwtVerify } from "jose"
const SESSION_COOKIE_OPTIONS = {
httpOnly: true, // Inaccessible to JavaScript — prevents XSS theft
secure: process.env.NODE_ENV === "production", // HTTPS only in prod
sameSite: "lax" as const, // Protects against CSRF
maxAge: 60 * 60 * 24 * 30, // 30 days in seconds
path: "/"
}
export async function createSession(userId: string) {
const sessionToken = await new SignJWT({ userId })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("30d")
.sign(new TextEncoder().encode(process.env.SESSION_SECRET!))
cookies().set("session", sessionToken, SESSION_COOKIE_OPTIONS)
}
The three non-negotiables for session cookies:
HttpOnly: Prevents JavaScript from reading the cookie — this is your primary XSS defense for auth tokensSecure: Only sent over HTTPS — non-negotiable in productionSameSite=Lax: Prevents the browser from sending the cookie on cross-site requests (CSRF protection)
Middleware-Based Route Protection
In the Next.js App Router, the cleanest way to protect routes is with middleware that runs before any page renders:
// middleware.ts
import { auth } from "@/lib/auth"
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
export async function middleware(request: NextRequest) {
const session = await auth()
const { pathname } = request.nextUrl
// Protected routes pattern
const isProtectedRoute = pathname.startsWith("/dashboard") ||
pathname.startsWith("/account") ||
pathname.startsWith("/admin")
if (isProtectedRoute && !session) {
const loginUrl = new URL("/login", request.url)
loginUrl.searchParams.set("callbackUrl", pathname)
return NextResponse.redirect(loginUrl)
}
// Role-based access
if (pathname.startsWith("/admin") && session?.user.role !== "admin") {
return NextResponse.redirect(new URL("/unauthorized", request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ["/dashboard/:path*", "/account/:path*", "/admin/:path*"]
}
The matcher config limits which routes the middleware runs on — don’t run auth checks on static assets or public pages, as it adds latency to every request.
Refresh Token Rotation
If you’re using JWTs (or OAuth tokens with expiry), refresh token rotation is essential security hygiene. The pattern:
- Issue a short-lived access token (15-60 minutes)
- Issue a long-lived refresh token (30 days)
- When the access token expires, use the refresh token to get a new access token — and issue a new refresh token (rotating it)
- Invalidate the old refresh token
The rotation means a stolen refresh token has a limited window: the next time your legitimate user uses it to refresh, the old one is invalidated. If the attacker uses it first, your user’s next refresh attempt fails and they’re logged out — and you’ve detected the compromise.
export async function rotateRefreshToken(oldRefreshToken: string) {
// Verify the refresh token
const tokenRecord = await db.refreshToken.findUnique({
where: { token: oldRefreshToken }
})
if (!tokenRecord || tokenRecord.expiresAt < new Date()) {
throw new Error("Invalid or expired refresh token")
}
// Check for token reuse (sign of theft)
if (tokenRecord.used) {
// Revoke all tokens for this user — possible compromise
await db.refreshToken.deleteMany({ where: { userId: tokenRecord.userId } })
throw new Error("Refresh token reuse detected — all sessions revoked")
}
// Mark as used and issue new tokens
await db.refreshToken.update({
where: { id: tokenRecord.id },
data: { used: true }
})
const newAccessToken = generateAccessToken(tokenRecord.userId)
const newRefreshToken = await generateRefreshToken(tokenRecord.userId)
return { accessToken: newAccessToken, refreshToken: newRefreshToken }
}
The reuse detection (if tokenRecord.used is already true) is the key security primitive. Legitimate users never use a refresh token twice. If one is reused, it signals a theft and you revoke everything for that user.
What I Reach For
For projects with social login (Google, GitHub): NextAuth with database sessions. It’s mature, secure by default, and handles all the OAuth complexity.
For internal APIs or services: short-lived JWTs with a token blocklist for revocation.
For anything involving real money or sensitive data: database sessions + refresh token rotation + refresh on every request validation. Don’t cut corners.
Auth is one place where “good enough for a demo” is genuinely dangerous in production. The patterns here are battle-tested — use them as starting points, not endpoints.