Authentication Patterns in Modern Web Apps
After implementing authentication systems for over 20 web applications—from simple blogs to enterprise SaaS platforms—I've learned that authentication is never just about verifying users. It's about balancing security, user experience, and maintainability. Here's the complete guide to modern authentication patterns that I wish I had when I started.
The Authentication Security Spectrum
Every authentication decision exists on a spectrum between security and usability. Here's how I evaluate each approach:
// types/auth-evaluation.ts
interface AuthPattern {
name: string;
security: 'low' | 'medium' | 'high' | 'very-high';
userExperience: 'poor' | 'fair' | 'good' | 'excellent';
implementationComplexity: 'simple' | 'moderate' | 'complex' | 'very-complex';
maintenanceOverhead: 'low' | 'medium' | 'high';
useCases: string[];
pitfalls: string[];
}
const authPatterns: AuthPattern[] = [
{
name: 'JWT with Refresh Tokens',
security: 'high',
userExperience: 'good',
implementationComplexity: 'moderate',
maintenanceOverhead: 'medium',
useCases: ['SPAs', 'Mobile apps', 'Microservices', 'API-first apps'],
pitfalls: ['Token revocation complexity', 'XSS vulnerabilities', 'Storage decisions']
},
{
name: 'Session-Based Auth',
security: 'high',
userExperience: 'good',
implementationComplexity: 'simple',
maintenanceOverhead: 'low',
useCases: ['Traditional web apps', 'Server-rendered applications'],
pitfalls: ['CSRF attacks', 'Scaling challenges', 'Load balancer complexity']
},
{
name: 'OAuth 2.0 + OIDC',
security: 'very-high',
userExperience: 'excellent',
implementationComplexity: 'complex',
maintenanceOverhead: 'low',
useCases: ['Enterprise apps', 'B2B SaaS', 'Social login', 'SSO'],
pitfalls: ['Redirect complexity', 'State management', 'Provider dependencies']
}
];
JWT vs Sessions: The Practical Decision
After years of debate, here's my real-world experience with both approaches:
JWT Implementation with Security Best Practices
// lib/jwt-auth.ts
import jwt from 'jsonwebtoken';
import { cookies } from 'next/headers';
interface JWTPayload {
userId: string;
email: string;
role: string;
sessionId: string; // For revocation
iat: number;
exp: number;
}
interface TokenPair {
accessToken: string;
refreshToken: string;
}
class JWTAuthManager {
private accessTokenSecret = process.env.JWT_ACCESS_SECRET!;
private refreshTokenSecret = process.env.JWT_REFRESH_SECRET!;
private accessTokenExpiry = '15m'; // Short-lived
private refreshTokenExpiry = '7d'; // Longer-lived
generateTokenPair(payload: Omit<JWTPayload, 'iat' | 'exp'>): TokenPair {
const accessToken = jwt.sign(
payload,
this.accessTokenSecret,
{
expiresIn: this.accessTokenExpiry,
issuer: 'mikul.me',
audience: 'mikul.me-users'
}
);
const refreshToken = jwt.sign(
{ userId: payload.userId, sessionId: payload.sessionId },
this.refreshTokenSecret,
{
expiresIn: this.refreshTokenExpiry,
issuer: 'mikul.me',
audience: 'mikul.me-users'
}
);
return { accessToken, refreshToken };
}
verifyAccessToken(token: string): JWTPayload | null {
try {
const payload = jwt.verify(token, this.accessTokenSecret, {
issuer: 'mikul.me',
audience: 'mikul.me-users'
}) as JWTPayload;
// Check if session is still active (for revocation)
if (!this.isSessionActive(payload.sessionId)) {
return null;
}
return payload;
} catch (error) {
return null;
}
}
async refreshTokens(refreshToken: string): Promise<TokenPair | null> {
try {
const payload = jwt.verify(refreshToken, this.refreshTokenSecret) as {
userId: string;
sessionId: string;
};
// Check if refresh token is blacklisted
if (await this.isTokenBlacklisted(refreshToken)) {
return null;
}
// Blacklist the old refresh token
await this.blacklistToken(refreshToken);
// Get fresh user data
const user = await this.getUserById(payload.userId);
if (!user || !this.isSessionActive(payload.sessionId)) {
return null;
}
// Generate new token pair
return this.generateTokenPair({
userId: user.id,
email: user.email,
role: user.role,
sessionId: payload.sessionId
});
} catch (error) {
return null;
}
}
// Secure cookie management
setAuthCookies(tokenPair: TokenPair) {
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict' as const,
path: '/'
};
cookies().set('accessToken', tokenPair.accessToken, {
...cookieOptions,
maxAge: 15 * 60 // 15 minutes
});
cookies().set('refreshToken', tokenPair.refreshToken, {
...cookieOptions,
maxAge: 7 * 24 * 60 * 60 // 7 days
});
}
private async isTokenBlacklisted(token: string): Promise<boolean> {
// Check Redis or database for blacklisted tokens
return false; // Implementation depends on your storage
}
private async blacklistToken(token: string): Promise<void> {
// Add token to blacklist with expiry
}
private isSessionActive(sessionId: string): boolean {
// Check if session is still valid
return true; // Implementation depends on your session storage
}
private async getUserById(userId: string) {
// Fetch user from database
return null;
}
}
export const jwtAuth = new JWTAuthManager();
Session-Based Authentication
// lib/session-auth.ts
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { cache } from 'react';
interface Session {
id: string;
userId: string;
email: string;
role: string;
createdAt: Date;
expiresAt: Date;
ipAddress: string;
userAgent: string;
}
class SessionManager {
private sessionExpiry = 24 * 60 * 60 * 1000; // 24 hours
async createSession(userId: string, email: string, role: string, request: Request): Promise<string> {
const sessionId = this.generateSessionId();
const expiresAt = new Date(Date.now() + this.sessionExpiry);
const session: Session = {
id: sessionId,
userId,
email,
role,
createdAt: new Date(),
expiresAt,
ipAddress: this.getClientIP(request),
userAgent: request.headers.get('user-agent') || ''
};
// Store session in database/Redis
await this.storeSession(session);
// Set secure cookie
cookies().set('sessionId', sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
expires: expiresAt,
path: '/'
});
return sessionId;
}
// Cache the session lookup for the duration of the request
getSession = cache(async (): Promise<Session | null> => {
const sessionId = cookies().get('sessionId')?.value;
if (!sessionId) return null;
const session = await this.retrieveSession(sessionId);
if (!session || session.expiresAt < new Date()) {
await this.destroySession(sessionId);
return null;
}
return session;
});
async requireAuth(): Promise<Session> {
const session = await this.getSession();
if (!session) {
redirect('/auth/login');
}
return session;
}
async requireRole(role: string): Promise<Session> {
const session = await this.requireAuth();
if (session.role !== role && session.role !== 'admin') {
redirect('/unauthorized');
}
return session;
}
async destroySession(sessionId?: string): Promise<void> {
const targetSessionId = sessionId || cookies().get('sessionId')?.value;
if (!targetSessionId) return;
await this.removeSession(targetSessionId);
cookies().delete('sessionId');
}
async destroyAllUserSessions(userId: string): Promise<void> {
await this.removeAllUserSessions(userId);
}
private generateSessionId(): string {
return crypto.randomUUID();
}
private getClientIP(request: Request): string {
// Extract IP from headers, considering proxies
return request.headers.get('x-forwarded-for')?.split(',')[0] ||
request.headers.get('x-real-ip') ||
'unknown';
}
private async storeSession(session: Session): Promise<void> {
// Store in Redis or database
// Redis example: await redis.setex(`session:${session.id}`, this.sessionExpiry / 1000, JSON.stringify(session));
}
private async retrieveSession(sessionId: string): Promise<Session | null> {
// Retrieve from Redis or database
return null;
}
private async removeSession(sessionId: string): Promise<void> {
// Remove from storage
}
private async removeAllUserSessions(userId: string): Promise<void> {
// Remove all sessions for a user
}
}
export const sessionManager = new SessionManager();
OAuth 2.0 and OpenID Connect Implementation
For enterprise applications and social login, I use NextAuth.js with custom configuration:
// lib/auth.ts
import NextAuth, { NextAuthOptions } from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
import GitHubProvider from 'next-auth/providers/github';
import CredentialsProvider from 'next-auth/providers/credentials';
import { PrismaAdapter } from '@next-auth/prisma-adapter';
import { prisma } from './prisma';
import bcrypt from 'bcryptjs';
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
authorization: {
params: {
prompt: "consent",
access_type: "offline",
response_type: "code"
}
}
}),
GitHubProvider({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
}),
CredentialsProvider({
name: '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 prisma.user.findUnique({
where: { email: credentials.email }
});
if (!user || !user.hashedPassword) return null;
const isPasswordValid = await bcrypt.compare(
credentials.password,
user.hashedPassword
);
if (!isPasswordValid) return null;
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
};
}
})
],
session: {
strategy: 'jwt',
maxAge: 24 * 60 * 60, // 24 hours
updateAge: 60 * 60, // 1 hour
},
jwt: {
maxAge: 24 * 60 * 60,
},
callbacks: {
async jwt({ token, user, account }) {
// Initial sign in
if (user) {
token.role = user.role;
token.provider = account?.provider;
}
// Check if user still exists and is active
if (token.sub) {
const dbUser = await prisma.user.findUnique({
where: { id: token.sub },
select: { id: true, isActive: true, role: true }
});
if (!dbUser || !dbUser.isActive) {
// Force logout by returning empty token
return {};
}
// Update role if changed
token.role = dbUser.role;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.sub!;
session.user.role = token.role as string;
}
return session;
},
async signIn({ user, account, profile }) {
// Block sign-in for deactivated users
if (user.email) {
const dbUser = await prisma.user.findUnique({
where: { email: user.email },
select: { isActive: true }
});
if (dbUser && !dbUser.isActive) {
return false;
}
}
return true;
},
async redirect({ url, baseUrl }) {
// Allows relative callback URLs
if (url.startsWith("/")) return `${baseUrl}${url}`;
// Allows callback URLs on the same origin
else if (new URL(url).origin === baseUrl) return url;
return baseUrl;
}
},
pages: {
signIn: '/auth/signin',
error: '/auth/error',
newUser: '/auth/signup'
},
events: {
async signIn({ user, account, isNewUser }) {
console.log(`User ${user.email} signed in via ${account?.provider}`);
// Log successful authentication
await prisma.authLog.create({
data: {
userId: user.id,
action: 'SIGN_IN',
provider: account?.provider || 'credentials',
success: true
}
});
},
async signOut({ token }) {
console.log(`User ${token.email} signed out`);
}
},
debug: process.env.NODE_ENV === 'development',
};
export const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
Passwordless and Biometric Authentication
WebAuthn implementation for modern, secure authentication:
// lib/webauthn.ts
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse
} from '@simplewebauthn/server';
import type {
GenerateRegistrationOptionsOpts,
VerifyRegistrationResponseOpts,
GenerateAuthenticationOptionsOpts,
VerifyAuthenticationResponseOpts,
} from '@simplewebauthn/server';
const rpID = process.env.NODE_ENV === 'production' ? 'mikul.me' : 'localhost';
const rpName = 'Mikul Gohil';
const origin = process.env.NODE_ENV === 'production' ? 'https://mikul.me' : 'http://localhost:3000';
class WebAuthnManager {
async generateRegistrationOptions(userId: string, email: string) {
// Get existing authenticators for the user
const user = await prisma.user.findUnique({
where: { id: userId },
include: { authenticators: true }
});
if (!user) throw new Error('User not found');
const opts: GenerateRegistrationOptionsOpts = {
rpName,
rpID,
userID: userId,
userName: email,
userDisplayName: user.name || email,
attestationType: 'indirect',
excludeCredentials: user.authenticators.map(auth => ({
id: auth.credentialID,
type: 'public-key',
transports: auth.transports as AuthenticatorTransport[],
})),
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
authenticatorAttachment: 'platform', // Prefer platform authenticators
},
};
const options = await generateRegistrationOptions(opts);
// Store challenge for verification
await prisma.webAuthnChallenge.create({
data: {
userId,
challenge: options.challenge,
type: 'registration',
expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5 minutes
}
});
return options;
}
async verifyRegistration(userId: string, response: any) {
const challenge = await prisma.webAuthnChallenge.findFirst({
where: {
userId,
type: 'registration',
expiresAt: { gt: new Date() }
}
});
if (!challenge) {
throw new Error('Challenge not found or expired');
}
const opts: VerifyRegistrationResponseOpts = {
response,
expectedChallenge: challenge.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
};
const verification = await verifyRegistrationResponse(opts);
if (verification.verified && verification.registrationInfo) {
// Save authenticator to database
await prisma.authenticator.create({
data: {
userId,
credentialID: Buffer.from(verification.registrationInfo.credentialID),
publicKey: Buffer.from(verification.registrationInfo.credentialPublicKey),
counter: verification.registrationInfo.counter,
transports: response.response.transports || [],
name: this.getAuthenticatorName(response),
}
});
// Clean up challenge
await prisma.webAuthnChallenge.delete({
where: { id: challenge.id }
});
}
return verification;
}
async generateAuthenticationOptions(email?: string) {
let allowCredentials: { id: Buffer; transports?: AuthenticatorTransport[] }[] = [];
if (email) {
const user = await prisma.user.findUnique({
where: { email },
include: { authenticators: true }
});
if (user) {
allowCredentials = user.authenticators.map(auth => ({
id: auth.credentialID,
transports: auth.transports as AuthenticatorTransport[],
}));
}
}
const opts: GenerateAuthenticationOptionsOpts = {
rpID,
allowCredentials,
userVerification: 'preferred',
};
const options = await generateAuthenticationOptions(opts);
// Store challenge
const challengeRecord = await prisma.webAuthnChallenge.create({
data: {
challenge: options.challenge,
type: 'authentication',
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
}
});
return { options, challengeId: challengeRecord.id };
}
async verifyAuthentication(challengeId: string, response: any) {
const challenge = await prisma.webAuthnChallenge.findUnique({
where: { id: challengeId }
});
if (!challenge || challenge.expiresAt < new Date()) {
throw new Error('Challenge not found or expired');
}
// Find the authenticator
const authenticator = await prisma.authenticator.findUnique({
where: { credentialID: Buffer.from(response.id, 'base64url') },
include: { user: true }
});
if (!authenticator) {
throw new Error('Authenticator not found');
}
const opts: VerifyAuthenticationResponseOpts = {
response,
expectedChallenge: challenge.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
authenticator: {
credentialID: authenticator.credentialID,
credentialPublicKey: authenticator.publicKey,
counter: authenticator.counter,
},
};
const verification = await verifyAuthenticationResponse(opts);
if (verification.verified) {
// Update counter
await prisma.authenticator.update({
where: { id: authenticator.id },
data: {
counter: verification.authenticationInfo.newCounter,
lastUsed: new Date()
}
});
// Clean up challenge
await prisma.webAuthnChallenge.delete({
where: { id: challenge.id }
});
return { verified: true, user: authenticator.user };
}
return { verified: false };
}
private getAuthenticatorName(response: any): string {
// Try to determine authenticator type from response
const { authenticatorData } = response.response;
// This is simplified - in practice, you might want more sophisticated detection
return 'Security Key';
}
}
export const webAuthn = new WebAuthnManager();
Multi-Factor Authentication Implementation
// lib/mfa.ts
import { generateSecret, generateQRCode, verifyToken } from 'node-2fa';
import qrcode from 'qrcode';
class MFAManager {
async enableTOTP(userId: string, userEmail: string) {
const secret = generateSecret({
name: userEmail,
account: userEmail,
issuer: 'Mikul Gohil'
});
// Store the secret temporarily until verified
await prisma.tempMFASecret.create({
data: {
userId,
secret: secret.secret,
qrCode: secret.qr,
expiresAt: new Date(Date.now() + 10 * 60 * 1000) // 10 minutes
}
});
// Generate QR code for easier setup
const qrCodeDataURL = await qrcode.toDataURL(secret.uri);
return {
secret: secret.secret,
qrCode: qrCodeDataURL,
manualEntryKey: secret.secret
};
}
async verifyAndActivateTOTP(userId: string, token: string) {
const tempSecret = await prisma.tempMFASecret.findFirst({
where: {
userId,
expiresAt: { gt: new Date() }
}
});
if (!tempSecret) {
throw new Error('TOTP setup not found or expired');
}
const verification = verifyToken(tempSecret.secret, token);
if (!verification || verification.delta !== 0) {
throw new Error('Invalid TOTP code');
}
// Move to permanent storage
await prisma.user.update({
where: { id: userId },
data: {
mfaSecret: tempSecret.secret,
mfaEnabled: true
}
});
// Generate backup codes
const backupCodes = this.generateBackupCodes();
await prisma.mfaBackupCode.createMany({
data: backupCodes.map(code => ({
userId,
code,
used: false
}))
});
// Clean up temporary secret
await prisma.tempMFASecret.delete({
where: { id: tempSecret.id }
});
return { success: true, backupCodes };
}
async verifyTOTP(userId: string, token: string): Promise<boolean> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { mfaSecret: true, mfaEnabled: true }
});
if (!user?.mfaEnabled || !user.mfaSecret) {
return false;
}
const verification = verifyToken(user.mfaSecret, token);
return verification !== null && Math.abs(verification.delta) <= 1;
}
async verifyBackupCode(userId: string, code: string): Promise<boolean> {
const backupCode = await prisma.mfaBackupCode.findFirst({
where: {
userId,
code,
used: false
}
});
if (!backupCode) return false;
// Mark as used
await prisma.mfaBackupCode.update({
where: { id: backupCode.id },
data: { used: true, usedAt: new Date() }
});
return true;
}
private generateBackupCodes(): string[] {
const codes: string[] = [];
for (let i = 0; i < 10; i++) {
codes.push(this.generateBackupCode());
}
return codes;
}
private generateBackupCode(): string {
return Math.random().toString(36).substring(2, 10).toUpperCase();
}
}
export const mfa = new MFAManager();
Security Best Practices in Production
After experiencing security incidents, here are the non-negotiable practices I implement:
// middleware.ts - Security Headers and Rate Limiting
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, '10 s'), // 10 requests per 10 seconds
analytics: true,
});
export async function middleware(request: NextRequest) {
const response = NextResponse.next();
// Security headers
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-XSS-Protection', '1; mode=block');
response.headers.set('Referrer-Policy', 'origin-when-cross-origin');
if (process.env.NODE_ENV === 'production') {
response.headers.set(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains; preload'
);
response.headers.set(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self';"
);
}
// Rate limiting for auth endpoints
if (request.nextUrl.pathname.startsWith('/api/auth/')) {
const ip = request.ip ?? '127.0.0.1';
const { success } = await ratelimit.limit(ip);
if (!success) {
return NextResponse.json(
{ error: 'Too many requests' },
{ status: 429 }
);
}
}
return response;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
The key insight I've learned: authentication is not just about verifying identity—it's about creating a security-first culture in your application. Start with the simplest solution that meets your security requirements, then add complexity only when necessary. Most importantly, always prioritize the security of your users' data over convenience features.