Designing Authentication the Right Way in Next.js Apps
Designing Authentication the Right Way in Next.js Apps
JWTs vs sessions vs OAuth — the tradeoffs most teams get wrong, and a production-grade auth architecture walkthrough
The Authentication Minefield
Authentication seems simple until you're debugging why users are randomly logged out, why your JWT secret rotated in production and locked out 50,000 users, or why your session table has 40 million rows and counting.
I've audited dozens of Next.js applications, and authentication is consistently where teams make their most consequential mistakes. Not because auth is inherently hard, but because the tradeoffs aren't obvious until something breaks.
Let's fix that.
Understanding the Options
The Authentication Landscape
┌─────────────────────────────────────────────────────────────────────────────┐
│ AUTHENTICATION STRATEGIES │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ SESSION-BASED AUTH │ │
│ ├─────────────────────────────────────────────────────────────────────┤ │
│ │ Client Server Database │ │
│ │ │ │ │ │ │
│ │ │─── Login ─────►│ │ │ │
│ │ │ │── Create ─────►│ │ │
│ │ │ │ Session │ │ │
│ │ │◄─ Cookie ──────│ │ │ │
│ │ │ (session_id) │ │ │ │
│ │ │ │ │ │ │
│ │ │─── Request ───►│── Lookup ─────►│ │ │
│ │ │ + Cookie │ Session │ │ │
│ │ │◄── Response ───│◄── User ───────│ │ │
│ │ │ │
│ │ Pros: Revocable, server-controlled, simple │ │
│ │ Cons: Database lookup per request, scaling complexity │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ JWT-BASED AUTH │ │
│ ├─────────────────────────────────────────────────────────────────────┤ │
│ │ Client Server │ │
│ │ │ │ │ │
│ │ │─── Login ─────►│ │ │
│ │ │ │── Sign JWT │ │
│ │ │◄─ JWT Token ───│ │ │
│ │ │ │ │ │
│ │ │─── Request ───►│── Verify │ │
│ │ │ + JWT │ Signature │ │
│ │ │◄── Response ───│ (no DB) │ │
│ │ │ │
│ │ Pros: Stateless, no DB lookup, scales horizontally │ │
│ │ Cons: Can't revoke, size limits, complexity with refresh │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ OAUTH / OIDC │ │
│ ├─────────────────────────────────────────────────────────────────────┤ │
│ │ Client Your Server Identity Provider │ │
│ │ │ │ │ │ │
│ │ │─ Login ─────►│ │ │ │
│ │ │◄─ Redirect ──│ │ │ │
│ │ │─────────────────────────────►│ │ │
│ │ │ │ Auth + Consent │ │
│ │ │◄───────────── Redirect + Code │ │ │
│ │ │─ Code ──────►│ │ │ │
│ │ │ │── Exchange ───►│ │ │
│ │ │ │◄─ Tokens ──────│ │ │
│ │ │◄─ Session ───│ │ │ │
│ │ │ │
│ │ Pros: Delegated auth, SSO, no password storage │ │
│ │ Cons: Complexity, external dependency, redirect UX │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
The Tradeoffs Most Teams Get Wrong
Mistake #1: "JWTs are Always Better Because They're Stateless"
┌─────────────────────────────────────────────────────────────────────────────┐
│ THE JWT REVOCATION PROBLEM │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Scenario: User reports account compromised. You need to log them out. │
│ │
│ With Sessions: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ DELETE FROM sessions WHERE user_id = 'compromised_user'; │ │
│ │ // Done. User is logged out immediately. │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ With JWTs: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ // Option 1: Wait for token to expire (unacceptable) │ │
│ │ // If token expires in 15 minutes, attacker has 15 minutes │ │
│ │ │ │
│ │ // Option 2: Blocklist the token (now you have state!) │ │
│ │ await redis.set(`blocked:${jti}`, '1', 'EX', tokenTTL); │ │
│ │ // Now you're checking Redis on every request anyway │ │
│ │ │ │
│ │ // Option 3: Rotate signing key (logs out EVERYONE) │ │
│ │ // Nuclear option, terrible UX │ │
│ │ │ │
│ │ // Option 4: Store token version per user (now you have state!) │ │
│ │ if (token.version < user.tokenVersion) reject(); │ │
│ │ // Checking database on every request anyway │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Reality: The moment you need revocation, JWTs lose their main advantage. │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Mistake #2: "Sessions Don't Scale"
// The "sessions don't scale" myth
// What people imagine:
// Every request → PostgreSQL query → 50ms latency → everything is slow
// What actually happens with proper implementation:
// Every request → Redis lookup → 1ms latency → just fine
// Session lookup with Redis
class SessionStore {
constructor(private redis: Redis) {}
async get(sessionId: string): Promise<Session | null> {
const data = await this.redis.get(`session:${sessionId}`);
if (!data) return null;
return JSON.parse(data);
}
async set(session: Session): Promise<void> {
await this.redis.setex(
`session:${session.id}`,
86400 * 7, // 7 days
JSON.stringify(session)
);
}
async destroy(sessionId: string): Promise<void> {
await this.redis.del(`session:${sessionId}`);
}
// Revoke all sessions for a user - trivial with sessions
async revokeAllForUser(userId: string): Promise<void> {
const keys = await this.redis.keys(`session:*:${userId}`);
if (keys.length > 0) {
await this.redis.del(...keys);
}
}
}
// Performance comparison (real numbers from production):
// Redis session lookup: 0.5-2ms
// JWT verification: 0.1-0.5ms
// PostgreSQL session lookup: 5-20ms
//
// For most applications, 1-2ms difference is negligible.
// The operational benefits of sessions often outweigh the performance difference.
Mistake #3: "OAuth Means I Don't Need My Own Auth System"
┌─────────────────────────────────────────────────────────────────────────────┐
│ OAUTH IS NOT A COMPLETE AUTH SYSTEM │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ What OAuth Gives You: │
│ ✓ User identity verification (they are who they say they are) │
│ ✓ Access tokens for external APIs (Google Calendar, etc.) │
│ ✓ No password storage │
│ │
│ What You Still Need: │
│ • Your own user records (OAuth ID ≠ your user ID) │
│ • Your own session management (after OAuth, you still need sessions) │
│ • Your own authorization (OAuth says WHO, not WHAT they can do) │
│ • Account linking (what if user signs up with Google, then email?) │
│ • Fallback auth (what if Google is down?) │
│ • Service accounts (how do APIs authenticate?) │
│ │
│ Common Architecture Mistake: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ❌ Storing Google's access_token and using it for your sessions │ │
│ │ - Token expires when Google says, not when you say │ │
│ │ - Can't add claims/permissions │ │
│ │ - Tied to Google's availability │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Correct Architecture: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ✅ Use OAuth for initial authentication │ │
│ │ → Create YOUR session/token with YOUR user ID │ │
│ │ → Add YOUR permissions and claims │ │
│ │ → Control expiration and revocation yourself │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
When to Use What
Decision Framework
┌─────────────────────────────────────────────────────────────────────────────┐
│ AUTH STRATEGY DECISION TREE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Do you need instant logout/revocation? │
│ │ │
│ ┌──────────────┼──────────────┐ │
│ ▼ ▼ ▼ │
│ Yes Maybe No │
│ │ │ │ │
│ ▼ │ ▼ │
│ Sessions │ Short-lived │
│ │ │ JWTs OK │
│ │ │ │ │
│ │ ▼ │ │
│ │ Is horizontal scaling │
│ │ critical with no Redis? │
│ │ │ │
│ │ ┌────────┼────────┐ │
│ │ ▼ ▼ ▼ │
│ │ Yes Maybe No │
│ │ │ │ │ │
│ │ ▼ │ ▼ │
│ │ JWTs │ Sessions │
│ │ + blocklist│ + Redis │
│ │ │ │
│ │ ▼ │
│ │ Either works │
│ │ (prefer sessions │
│ │ for simplicity) │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ Do you need SSO or social login? │ │
│ └────────────────┬────────────────────────┘ │
│ │ │
│ ┌──────────┼──────────┐ │
│ ▼ ▼ ▼ │
│ Yes Some No │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ OAuth + OAuth for Email/Pass │
│ Sessions social, + Sessions │
│ Email/Pass │
│ fallback │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
My Recommendations
┌─────────────────────────────────────────────────────────────────────────────┐
│ PRACTICAL RECOMMENDATIONS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ For Most Next.js Apps (B2B SaaS, Consumer Apps): │
│ ─────────────────────────────────────────────────── │
│ • Sessions stored in Redis │
│ • OAuth for social login (optional) │
│ • Email/password as fallback │
│ • HTTP-only secure cookies │
│ │
│ Why: Simple, secure, instant revocation, easy to debug │
│ │
│ ───────────────────────────────────────────────────────────────────── │
│ │
│ For API-First / Mobile Apps: │
│ ───────────────────────────── │
│ • Short-lived JWTs (15 min) for API access │
│ • Long-lived refresh tokens (stored, revocable) │
│ • Sessions for web dashboard │
│ │
│ Why: Mobile apps need tokens, refresh flow handles expiration │
│ │
│ ───────────────────────────────────────────────────────────────────── │
│ │
│ For Microservices / Service-to-Service: │
│ ────────────────────────────────────── │
│ • JWTs for service-to-service (no user context needed) │
│ • Short expiration, automated rotation │
│ • mTLS where possible │
│ │
│ Why: No revocation needed, services don't get "logged out" │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Production-Grade Auth Architecture
The Full Architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ PRODUCTION AUTH ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ │
│ │ Browser │ │
│ └──────┬───────┘ │
│ │ │
│ HTTP-only Secure Cookie (session_id) │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ NEXT.JS APP │ │
│ │ ┌───────────────────────────────────────────────────────────────┐ │ │
│ │ │ MIDDLEWARE │ │ │
│ │ │ • Validate session cookie │ │ │
│ │ │ • Attach user to request │ │ │
│ │ │ • Redirect if unauthenticated │ │ │
│ │ └───────────────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌────────────────────┼────────────────────┐ │ │
│ │ ▼ ▼ ▼ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Server │ │ API │ │ Server │ │ │
│ │ │ Components │ │ Routes │ │ Actions │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ getUser() │ │ getUser() │ │ getUser() │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ │ │ │ │
│ └──────────────────────────────┼───────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Redis │ │
│ │ Sessions │ │
│ └──────┬──────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ PostgreSQL │ │
│ │ Users │ │
│ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Implementation
1. Database Schema
// prisma/schema.prisma
model User {
id String @id @default(cuid())
email String @unique
emailVerified DateTime?
passwordHash String? // Null for OAuth-only users
name String?
image String?
role Role @default(USER)
// Security
tokenVersion Int @default(0) // Increment to invalidate all sessions
lastLoginAt DateTime?
lastLoginIp String?
// Relations
accounts Account[] // OAuth accounts
sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([email])
}
model Account {
id String @id @default(cuid())
userId String
type String // "oauth" | "email"
provider String // "google" | "github" | "credentials"
providerAccountId String
// OAuth tokens (encrypted at rest)
accessToken String? @db.Text
refreshToken String? @db.Text
expiresAt Int?
tokenType String?
scope String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@index([userId])
}
model Session {
id String @id @default(cuid())
userId String
expiresAt DateTime
// Session metadata (for security dashboard)
userAgent String?
ipAddress String?
lastActiveAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
}
model VerificationToken {
identifier String // email
token String @unique
type TokenType // EMAIL_VERIFICATION | PASSWORD_RESET | MAGIC_LINK
expiresAt DateTime
@@unique([identifier, token])
@@index([expiresAt])
}
enum Role {
USER
ADMIN
SUPER_ADMIN
}
enum TokenType {
EMAIL_VERIFICATION
PASSWORD_RESET
MAGIC_LINK
}
2. Session Management
// lib/auth/session.ts
import { cookies } from 'next/headers';
import { Redis } from 'ioredis';
import { prisma } from '@/lib/prisma';
import { nanoid } from 'nanoid';
const redis = new Redis(process.env.REDIS_URL!);
const SESSION_COOKIE_NAME = 'session_id';
const SESSION_TTL = 60 * 60 * 24 * 7; // 7 days
export interface SessionData {
id: string;
userId: string;
userAgent?: string;
ipAddress?: string;
createdAt: number;
lastActiveAt: number;
tokenVersion: number; // To invalidate when user.tokenVersion changes
}
export interface SessionUser {
id: string;
email: string;
name: string | null;
image: string | null;
role: string;
emailVerified: boolean;
}
// Create a new session
export async function createSession(
userId: string,
options: { userAgent?: string; ipAddress?: string }
): Promise<string> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { tokenVersion: true }
});
if (!user) throw new Error('User not found');
const sessionId = nanoid(32);
const sessionData: SessionData = {
id: sessionId,
userId,
userAgent: options.userAgent,
ipAddress: options.ipAddress,
createdAt: Date.now(),
lastActiveAt: Date.now(),
tokenVersion: user.tokenVersion
};
// Store in Redis
await redis.setex(
`session:${sessionId}`,
SESSION_TTL,
JSON.stringify(sessionData)
);
// Also store in database for session management UI
await prisma.session.create({
data: {
id: sessionId,
userId,
expiresAt: new Date(Date.now() + SESSION_TTL * 1000),
userAgent: options.userAgent,
ipAddress: options.ipAddress
}
});
// Set cookie
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE_NAME, sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: SESSION_TTL,
path: '/'
});
// Update user's last login
await prisma.user.update({
where: { id: userId },
data: {
lastLoginAt: new Date(),
lastLoginIp: options.ipAddress
}
});
return sessionId;
}
// Get current session and user
export async function getSession(): Promise<{
session: SessionData;
user: SessionUser;
} | null> {
const cookieStore = await cookies();
const sessionId = cookieStore.get(SESSION_COOKIE_NAME)?.value;
if (!sessionId) return null;
// Get session from Redis
const sessionJson = await redis.get(`session:${sessionId}`);
if (!sessionJson) return null;
const session: SessionData = JSON.parse(sessionJson);
// Get user and verify tokenVersion
const user = await prisma.user.findUnique({
where: { id: session.userId },
select: {
id: true,
email: true,
name: true,
image: true,
role: true,
emailVerified: true,
tokenVersion: true
}
});
if (!user) {
// User deleted, destroy session
await destroySession(sessionId);
return null;
}
// Check if session was invalidated via tokenVersion
if (session.tokenVersion !== user.tokenVersion) {
await destroySession(sessionId);
return null;
}
// Refresh session TTL if active (sliding window)
const now = Date.now();
if (now - session.lastActiveAt > 60 * 1000) { // Update every minute
session.lastActiveAt = now;
await redis.setex(
`session:${sessionId}`,
SESSION_TTL,
JSON.stringify(session)
);
await prisma.session.update({
where: { id: sessionId },
data: { lastActiveAt: new Date() }
}).catch(() => {}); // Ignore if session not in DB
}
return {
session,
user: {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
role: user.role,
emailVerified: !!user.emailVerified
}
};
}
// Destroy a session
export async function destroySession(sessionId?: string): Promise<void> {
const cookieStore = await cookies();
if (!sessionId) {
sessionId = cookieStore.get(SESSION_COOKIE_NAME)?.value;
}
if (sessionId) {
await redis.del(`session:${sessionId}`);
await prisma.session.delete({ where: { id: sessionId } }).catch(() => {});
}
cookieStore.delete(SESSION_COOKIE_NAME);
}
// Destroy all sessions for a user (logout everywhere)
export async function destroyAllUserSessions(userId: string): Promise<void> {
// Increment tokenVersion to invalidate all sessions
await prisma.user.update({
where: { id: userId },
data: { tokenVersion: { increment: 1 } }
});
// Clean up session records
const sessions = await prisma.session.findMany({
where: { userId },
select: { id: true }
});
// Delete from Redis
if (sessions.length > 0) {
await redis.del(...sessions.map(s => `session:${s.id}`));
}
// Delete from database
await prisma.session.deleteMany({ where: { userId } });
}
// Get all active sessions for a user (for security settings)
export async function getUserSessions(userId: string): Promise<{
id: string;
userAgent: string | null;
ipAddress: string | null;
lastActiveAt: Date;
isCurrent: boolean;
}[]> {
const cookieStore = await cookies();
const currentSessionId = cookieStore.get(SESSION_COOKIE_NAME)?.value;
const sessions = await prisma.session.findMany({
where: {
userId,
expiresAt: { gt: new Date() }
},
orderBy: { lastActiveAt: 'desc' }
});
return sessions.map(s => ({
id: s.id,
userAgent: s.userAgent,
ipAddress: s.ipAddress,
lastActiveAt: s.lastActiveAt,
isCurrent: s.id === currentSessionId
}));
}
3. Middleware
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// Routes that require authentication
const protectedRoutes = ['/dashboard', '/settings', '/api/user'];
// Routes that should redirect to dashboard if authenticated
const authRoutes = ['/login', '/signup', '/forgot-password'];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Check for session cookie
const sessionId = request.cookies.get('session_id')?.value;
// Quick validation - full validation happens in getSession()
const hasSession = !!sessionId;
// Protect authenticated routes
if (protectedRoutes.some(route => pathname.startsWith(route))) {
if (!hasSession) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}
}
// Redirect authenticated users away from auth pages
if (authRoutes.some(route => pathname.startsWith(route))) {
if (hasSession) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
}
// Add security headers
const response = NextResponse.next();
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set(
'Permissions-Policy',
'camera=(), microphone=(), geolocation=()'
);
return response;
}
export const config = {
matcher: [
/*
* Match all paths except:
* - _next/static (static files)
* - _next/image (image optimization)
* - favicon.ico
* - public files
*/
'/((?!_next/static|_next/image|favicon.ico|.*\\..*|api/health).*)',
],
};
4. Authentication Actions
// lib/auth/actions.ts
'use server';
import { z } from 'zod';
import bcrypt from 'bcryptjs';
import { prisma } from '@/lib/prisma';
import { createSession, destroySession, destroyAllUserSessions } from './session';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { rateLimit } from '@/lib/rate-limit';
import { sendEmail } from '@/lib/email';
import { nanoid } from 'nanoid';
// Schemas
const signUpSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
.regex(/[a-z]/, 'Password must contain a lowercase letter')
.regex(/[0-9]/, 'Password must contain a number'),
name: z.string().min(2, 'Name must be at least 2 characters').optional()
});
const signInSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(1, 'Password is required')
});
// Helper to get request metadata
async function getRequestMeta() {
const headersList = await headers();
return {
userAgent: headersList.get('user-agent') || undefined,
ipAddress: headersList.get('x-forwarded-for')?.split(',')[0] ||
headersList.get('x-real-ip') ||
undefined
};
}
// Sign Up
export async function signUp(formData: FormData) {
const rawData = {
email: formData.get('email'),
password: formData.get('password'),
name: formData.get('name')
};
// Validate input
const result = signUpSchema.safeParse(rawData);
if (!result.success) {
return {
error: result.error.errors[0].message
};
}
const { email, password, name } = result.data;
const meta = await getRequestMeta();
// Rate limit by IP
const rateLimitResult = await rateLimit(`signup:${meta.ipAddress}`, {
limit: 5,
window: 60 * 60 // 5 signups per hour per IP
});
if (!rateLimitResult.success) {
return {
error: 'Too many signup attempts. Please try again later.'
};
}
// Check if user exists
const existingUser = await prisma.user.findUnique({
where: { email: email.toLowerCase() }
});
if (existingUser) {
// Don't reveal if email exists (timing-safe)
// Send "account already exists" email instead
await sendEmail({
to: email,
template: 'account-exists',
data: { email }
});
// Return same response as success (security)
return {
success: true,
message: 'Check your email to verify your account.'
};
}
// Hash password
const passwordHash = await bcrypt.hash(password, 12);
// Create user
const user = await prisma.user.create({
data: {
email: email.toLowerCase(),
passwordHash,
name,
accounts: {
create: {
type: 'email',
provider: 'credentials',
providerAccountId: email.toLowerCase()
}
}
}
});
// Create verification token
const token = nanoid(32);
await prisma.verificationToken.create({
data: {
identifier: email.toLowerCase(),
token,
type: 'EMAIL_VERIFICATION',
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours
}
});
// Send verification email
await sendEmail({
to: email,
template: 'verify-email',
data: {
name: name || email,
verifyUrl: `${process.env.NEXT_PUBLIC_APP_URL}/verify-email?token=${token}`
}
});
return {
success: true,
message: 'Check your email to verify your account.'
};
}
// Sign In
export async function signIn(formData: FormData) {
const rawData = {
email: formData.get('email'),
password: formData.get('password')
};
const result = signInSchema.safeParse(rawData);
if (!result.success) {
return { error: result.error.errors[0].message };
}
const { email, password } = result.data;
const meta = await getRequestMeta();
// Rate limit by email
const rateLimitResult = await rateLimit(`signin:${email.toLowerCase()}`, {
limit: 5,
window: 15 * 60 // 5 attempts per 15 minutes per email
});
if (!rateLimitResult.success) {
return {
error: 'Too many login attempts. Please try again in 15 minutes.'
};
}
// Find user
const user = await prisma.user.findUnique({
where: { email: email.toLowerCase() },
select: {
id: true,
email: true,
passwordHash: true,
emailVerified: true
}
});
// Constant-time comparison to prevent timing attacks
const isValidPassword = user?.passwordHash
? await bcrypt.compare(password, user.passwordHash)
: await bcrypt.compare(password, '$2a$12$dummy.hash.to.prevent.timing.attacks');
if (!user || !isValidPassword) {
return { error: 'Invalid email or password' };
}
// Check email verification (optional, based on your requirements)
if (!user.emailVerified) {
return {
error: 'Please verify your email before signing in.',
needsVerification: true
};
}
// Create session
await createSession(user.id, meta);
// Redirect to dashboard
redirect('/dashboard');
}
// Sign Out
export async function signOut() {
await destroySession();
redirect('/login');
}
// Sign Out Everywhere
export async function signOutEverywhere() {
const session = await getSession();
if (session) {
await destroyAllUserSessions(session.user.id);
}
redirect('/login');
}
// Forgot Password
export async function forgotPassword(formData: FormData) {
const email = formData.get('email') as string;
if (!email || !z.string().email().safeParse(email).success) {
return { error: 'Invalid email address' };
}
const meta = await getRequestMeta();
// Rate limit
const rateLimitResult = await rateLimit(`forgot:${meta.ipAddress}`, {
limit: 3,
window: 60 * 60 // 3 requests per hour per IP
});
if (!rateLimitResult.success) {
return { error: 'Too many requests. Please try again later.' };
}
const user = await prisma.user.findUnique({
where: { email: email.toLowerCase() }
});
// Always return success (don't reveal if email exists)
if (user) {
// Delete existing tokens
await prisma.verificationToken.deleteMany({
where: {
identifier: email.toLowerCase(),
type: 'PASSWORD_RESET'
}
});
// Create new token
const token = nanoid(32);
await prisma.verificationToken.create({
data: {
identifier: email.toLowerCase(),
token,
type: 'PASSWORD_RESET',
expiresAt: new Date(Date.now() + 60 * 60 * 1000) // 1 hour
}
});
await sendEmail({
to: email,
template: 'reset-password',
data: {
resetUrl: `${process.env.NEXT_PUBLIC_APP_URL}/reset-password?token=${token}`
}
});
}
return {
success: true,
message: 'If an account exists, you will receive a password reset email.'
};
}
// Reset Password
export async function resetPassword(formData: FormData) {
const token = formData.get('token') as string;
const password = formData.get('password') as string;
const passwordResult = z.string()
.min(8)
.regex(/[A-Z]/)
.regex(/[a-z]/)
.regex(/[0-9]/)
.safeParse(password);
if (!passwordResult.success) {
return { error: 'Password does not meet requirements' };
}
// Find token
const verificationToken = await prisma.verificationToken.findFirst({
where: {
token,
type: 'PASSWORD_RESET',
expiresAt: { gt: new Date() }
}
});
if (!verificationToken) {
return { error: 'Invalid or expired reset link' };
}
// Update password
const passwordHash = await bcrypt.hash(password, 12);
await prisma.user.update({
where: { email: verificationToken.identifier },
data: {
passwordHash,
tokenVersion: { increment: 1 } // Invalidate all existing sessions
}
});
// Delete token
await prisma.verificationToken.delete({
where: {
identifier_token: {
identifier: verificationToken.identifier,
token
}
}
});
return {
success: true,
message: 'Password reset successfully. Please sign in.'
};
}
5. OAuth Integration
// lib/auth/oauth.ts
import { OAuth2Client } from 'google-auth-library';
import { prisma } from '@/lib/prisma';
import { createSession } from './session';
import { headers } from 'next/headers';
const googleClient = new OAuth2Client(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
`${process.env.NEXT_PUBLIC_APP_URL}/api/auth/callback/google`
);
// Generate OAuth URL
export function getGoogleAuthUrl(state: string): string {
return googleClient.generateAuthUrl({
access_type: 'offline',
scope: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile'
],
state,
prompt: 'consent'
});
}
// Handle OAuth callback
export async function handleGoogleCallback(code: string) {
// Exchange code for tokens
const { tokens } = await googleClient.getToken(code);
// Verify ID token
const ticket = await googleClient.verifyIdToken({
idToken: tokens.id_token!,
audience: process.env.GOOGLE_CLIENT_ID
});
const payload = ticket.getPayload()!;
const { sub: googleId, email, name, picture } = payload;
if (!email) {
throw new Error('Email not provided by Google');
}
// Find or create user
let user = await prisma.user.findFirst({
where: {
OR: [
{ email: email.toLowerCase() },
{
accounts: {
some: {
provider: 'google',
providerAccountId: googleId
}
}
}
]
},
include: {
accounts: {
where: { provider: 'google' }
}
}
});
if (!user) {
// Create new user
user = await prisma.user.create({
data: {
email: email.toLowerCase(),
emailVerified: new Date(), // Google emails are verified
name,
image: picture,
accounts: {
create: {
type: 'oauth',
provider: 'google',
providerAccountId: googleId,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: tokens.expiry_date
? Math.floor(tokens.expiry_date / 1000)
: undefined
}
}
},
include: {
accounts: {
where: { provider: 'google' }
}
}
});
} else if (!user.accounts.length) {
// Link Google account to existing user
await prisma.account.create({
data: {
userId: user.id,
type: 'oauth',
provider: 'google',
providerAccountId: googleId,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: tokens.expiry_date
? Math.floor(tokens.expiry_date / 1000)
: undefined
}
});
// Update user info if not set
if (!user.image || !user.name) {
await prisma.user.update({
where: { id: user.id },
data: {
name: user.name || name,
image: user.image || picture,
emailVerified: user.emailVerified || new Date()
}
});
}
} else {
// Update tokens
await prisma.account.update({
where: {
provider_providerAccountId: {
provider: 'google',
providerAccountId: googleId
}
},
data: {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || undefined,
expiresAt: tokens.expiry_date
? Math.floor(tokens.expiry_date / 1000)
: undefined
}
});
}
// Create session
const headersList = await headers();
await createSession(user.id, {
userAgent: headersList.get('user-agent') || undefined,
ipAddress: headersList.get('x-forwarded-for')?.split(',')[0] || undefined
});
return user;
}
6. API Route Handler
// app/api/auth/callback/google/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { handleGoogleCallback } from '@/lib/auth/oauth';
import { cookies } from 'next/headers';
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const code = searchParams.get('code');
const state = searchParams.get('state');
const error = searchParams.get('error');
// Handle errors
if (error) {
return NextResponse.redirect(
new URL(`/login?error=${encodeURIComponent(error)}`, request.url)
);
}
// Verify state (CSRF protection)
const cookieStore = await cookies();
const savedState = cookieStore.get('oauth_state')?.value;
if (!state || state !== savedState) {
return NextResponse.redirect(
new URL('/login?error=invalid_state', request.url)
);
}
// Clear state cookie
cookieStore.delete('oauth_state');
if (!code) {
return NextResponse.redirect(
new URL('/login?error=no_code', request.url)
);
}
try {
await handleGoogleCallback(code);
// Get callback URL from state or default to dashboard
const callbackUrl = searchParams.get('callbackUrl') || '/dashboard';
return NextResponse.redirect(new URL(callbackUrl, request.url));
} catch (error) {
console.error('OAuth callback error:', error);
return NextResponse.redirect(
new URL('/login?error=oauth_failed', request.url)
);
}
}
7. React Components
// components/auth/login-form.tsx
'use client';
import { useActionState } from 'react';
import { signIn } from '@/lib/auth/actions';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Alert } from '@/components/ui/alert';
export function LoginForm() {
const [state, action, pending] = useActionState(signIn, null);
return (
<form action={action} className="space-y-4">
{state?.error && (
<Alert variant="destructive">{state.error}</Alert>
)}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
autoComplete="email"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
/>
</div>
<Button type="submit" className="w-full" disabled={pending}>
{pending ? 'Signing in...' : 'Sign in'}
</Button>
<div className="relative my-4">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<GoogleSignInButton />
</form>
);
}
function GoogleSignInButton() {
const handleGoogleSignIn = async () => {
// Generate state for CSRF protection
const state = crypto.randomUUID();
// Store state in cookie
document.cookie = `oauth_state=${state}; path=/; max-age=600; samesite=lax`;
// Redirect to Google
window.location.href = `/api/auth/google?state=${state}`;
};
return (
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleGoogleSignIn}
>
<GoogleIcon className="mr-2 h-4 w-4" />
Continue with Google
</Button>
);
}
8. Server Component Auth
// lib/auth/server.ts
import { getSession } from './session';
import { redirect } from 'next/navigation';
import { cache } from 'react';
// Cache the session lookup per request
export const getCurrentUser = cache(async () => {
const result = await getSession();
return result?.user ?? null;
});
// Require authentication - redirect if not logged in
export async function requireAuth() {
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
return user;
}
// Require specific role
export async function requireRole(role: string | string[]) {
const user = await requireAuth();
const roles = Array.isArray(role) ? role : [role];
if (!roles.includes(user.role)) {
redirect('/unauthorized');
}
return user;
}
// Usage in Server Components
// app/dashboard/page.tsx
import { requireAuth } from '@/lib/auth/server';
export default async function DashboardPage() {
const user = await requireAuth();
return (
<div>
<h1>Welcome, {user.name || user.email}</h1>
{/* Dashboard content */}
</div>
);
}
// app/admin/page.tsx
import { requireRole } from '@/lib/auth/server';
export default async function AdminPage() {
const user = await requireRole(['ADMIN', 'SUPER_ADMIN']);
return (
<div>
<h1>Admin Dashboard</h1>
{/* Admin content */}
</div>
);
}
Security Hardening
Rate Limiting
// lib/rate-limit.ts
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
interface RateLimitOptions {
limit: number;
window: number; // seconds
}
interface RateLimitResult {
success: boolean;
remaining: number;
reset: number;
}
export async function rateLimit(
key: string,
options: RateLimitOptions
): Promise<RateLimitResult> {
const now = Math.floor(Date.now() / 1000);
const windowStart = now - options.window;
// Use sorted set for sliding window
const redisKey = `ratelimit:${key}`;
// Remove old entries
await redis.zremrangebyscore(redisKey, 0, windowStart);
// Count current entries
const count = await redis.zcard(redisKey);
if (count >= options.limit) {
// Get oldest entry to calculate reset time
const oldest = await redis.zrange(redisKey, 0, 0, 'WITHSCORES');
const reset = oldest.length > 1
? parseInt(oldest[1]) + options.window
: now + options.window;
return {
success: false,
remaining: 0,
reset
};
}
// Add new entry
await redis.zadd(redisKey, now, `${now}:${Math.random()}`);
await redis.expire(redisKey, options.window);
return {
success: true,
remaining: options.limit - count - 1,
reset: now + options.window
};
}
CSRF Protection
// lib/auth/csrf.ts
import { cookies } from 'next/headers';
import { nanoid } from 'nanoid';
const CSRF_COOKIE_NAME = 'csrf_token';
const CSRF_HEADER_NAME = 'x-csrf-token';
export async function generateCsrfToken(): Promise<string> {
const token = nanoid(32);
const cookieStore = await cookies();
cookieStore.set(CSRF_COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/'
});
return token;
}
export async function validateCsrfToken(token: string): Promise<boolean> {
const cookieStore = await cookies();
const storedToken = cookieStore.get(CSRF_COOKIE_NAME)?.value;
if (!storedToken || !token) return false;
// Constant-time comparison
if (storedToken.length !== token.length) return false;
let result = 0;
for (let i = 0; i < storedToken.length; i++) {
result |= storedToken.charCodeAt(i) ^ token.charCodeAt(i);
}
return result === 0;
}
// Middleware for API routes
export async function csrfMiddleware(request: Request): Promise<boolean> {
// Skip for GET, HEAD, OPTIONS
if (['GET', 'HEAD', 'OPTIONS'].includes(request.method)) {
return true;
}
const token = request.headers.get(CSRF_HEADER_NAME);
if (!token) return false;
return validateCsrfToken(token);
}
Security Headers
// next.config.js
const securityHeaders = [
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload'
},
{
key: 'X-Frame-Options',
value: 'DENY'
},
{
key: 'X-Content-Type-Options',
value: 'nosniff'
},
{
key: 'X-XSS-Protection',
value: '1; mode=block'
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin'
},
{
key: 'Content-Security-Policy',
value: `
default-src 'self';
script-src 'self' 'unsafe-eval' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
`.replace(/\s+/g, ' ').trim()
}
];
module.exports = {
async headers() {
return [
{
source: '/(.*)',
headers: securityHeaders
}
];
}
};
Common Pitfalls and Solutions
┌─────────────────────────────────────────────────────────────────────────────┐
│ AUTHENTICATION PITFALLS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Pitfall: Storing JWTs in localStorage │
│ Risk: XSS attacks can steal tokens │
│ Solution: HTTP-only cookies, or memory + refresh tokens │
│ │
│ ───────────────────────────────────────────────────────────────────── │
│ │
│ Pitfall: Long-lived JWTs without refresh mechanism │
│ Risk: Compromised tokens valid for days/weeks │
│ Solution: Short-lived access (15min) + refresh tokens (7 days) │
│ │
│ ───────────────────────────────────────────────────────────────────── │
│ │
│ Pitfall: Not validating email before allowing login │
│ Risk: Account takeover via email change │
│ Solution: Require email verification, re-verify on email change │
│ │
│ ───────────────────────────────────────────────────────────────────── │
│ │
│ Pitfall: Revealing user existence via error messages │
│ Risk: Attacker can enumerate valid emails │
│ Solution: Same response for "user not found" and "wrong password" │
│ │
│ ───────────────────────────────────────────────────────────────────── │
│ │
│ Pitfall: No rate limiting on auth endpoints │
│ Risk: Brute force attacks │
│ Solution: Rate limit by IP and email, exponential backoff │
│ │
│ ───────────────────────────────────────────────────────────────────── │
│ │
│ Pitfall: Password reset tokens that don't expire │
│ Risk: Old emails become attack vectors │
│ Solution: Short expiration (1 hour), single use, invalidate on use │
│ │
│ ───────────────────────────────────────────────────────────────────── │
│ │
│ Pitfall: Not invalidating sessions on password change │
│ Risk: Attacker retains access after password reset │
│ Solution: Increment tokenVersion, invalidate all sessions │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Quick Reference
Auth Strategy Comparison
┌─────────────────────────────────────────────────────────────────────────────┐
│ STRATEGY COMPARISON MATRIX │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Sessions JWTs JWT+Refresh OAuth │
│ ──────────────────────────────────────────────────────────────────── │
│ Complexity Low Medium High High │
│ Revocation Instant None* Via refresh Varies │
│ Scalability Redis Excellent Good N/A │
│ Mobile friendly Cookie** Excellent Excellent Good │
│ Cross-domain Hard Easy Easy Built-in │
│ Security by default High Medium Medium High │
│ Debugging ease Easy Hard Hard Hard │
│ │
│ * Without blocklist │
│ ** Requires token mode for mobile │
│ │
│ ───────────────────────────────────────────────────────────────────── │
│ │
│ Recommended for Next.js: │
│ • Web app only → Sessions + Redis │
│ • Web + Mobile → Sessions (web) + JWT refresh (mobile) │
│ • SSO required → OAuth + Sessions │
│ • Microservices → JWT for service-to-service │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Security Checklist
## Authentication Security Checklist
### Password Handling
□ Passwords hashed with bcrypt (cost factor 12+)
□ Password requirements enforced (length, complexity)
□ Password breach checking (HaveIBeenPwned API)
□ No password in logs or error messages
### Session Security
□ Session IDs are cryptographically random (32+ bytes)
□ Sessions stored server-side (Redis/database)
□ Cookies are HttpOnly, Secure, SameSite
□ Session invalidated on logout
□ All sessions invalidated on password change
□ Session timeout implemented (idle + absolute)
### Rate Limiting
□ Login attempts rate limited per IP
□ Login attempts rate limited per account
□ Password reset rate limited
□ Signup rate limited
□ Exponential backoff on failures
### CSRF Protection
□ CSRF tokens for state-changing requests
□ SameSite cookie attribute set
□ Origin header validation
### OAuth Security
□ State parameter for CSRF protection
□ Tokens stored securely (encrypted at rest)
□ Token refresh implemented
□ Account linking handled securely
### General
□ HTTPS enforced everywhere
□ Security headers configured
□ Sensitive data not in URLs
□ Error messages don't leak information
□ Audit logging for auth events
Closing Thoughts
Authentication is one of those areas where "it works" and "it's secure" are very different things. The implementation that passes QA might have subtle vulnerabilities that only become apparent when you're dealing with a security incident at 3 AM.
My recommendation for most Next.js applications:
- Start with sessions + Redis — Simple, secure, instant revocation
- Add OAuth for social login — Users expect it, reduces password fatigue
- Use HTTP-only cookies — Don't fight the browser's security model
- Implement rate limiting from day one — Easier than adding it later
- Plan for "logout everywhere" — You'll need it for security incidents
The complexity of JWTs is only worth it when you genuinely need stateless authentication across multiple domains or services. For a typical Next.js app with a single domain? Sessions are simpler, more secure by default, and easier to debug.
Authentication isn't where you want to be clever. It's where you want to be boring, predictable, and secure.
Security is a process, not a destination. Implement these patterns, then schedule regular security reviews. The auth system you build today will need to evolve as threats evolve.
What did you think?