Authentication Patterns in Modern Web Apps

13 min read2529 words

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.