Back to Blog
AuthenticationSecurityJWTOAuth

Authentication Patterns for Modern Applications

Implement secure authentication the right way. From JWTs to sessions to OAuth, understand the patterns and trade-offs.

B
Bootspring Team
Engineering
July 20, 2025
6 min read

Authentication is fundamental to application security, yet it's easy to get wrong. Understanding the patterns, their trade-offs, and security implications helps you choose the right approach for your application.

Authentication vs Authorization#

Authentication: Verifying identity ("Who are you?") Authorization: Checking permissions ("What can you do?")

Session-Based Authentication#

How It Works#

1. User logs in with credentials 2. Server creates session, stores in database/Redis 3. Server sends session ID in cookie 4. Browser sends cookie with each request 5. Server validates session ID, retrieves user

Implementation#

1// Login endpoint 2app.post('/login', async (req, res) => { 3 const { email, password } = req.body; 4 5 const user = await db.user.findByEmail(email); 6 if (!user || !await verifyPassword(password, user.passwordHash)) { 7 return res.status(401).json({ error: 'Invalid credentials' }); 8 } 9 10 // Create session 11 const sessionId = crypto.randomUUID(); 12 await redis.set(`session:${sessionId}`, JSON.stringify({ 13 userId: user.id, 14 createdAt: Date.now(), 15 }), 'EX', 86400); // 24 hours 16 17 // Set cookie 18 res.cookie('sessionId', sessionId, { 19 httpOnly: true, 20 secure: true, 21 sameSite: 'lax', 22 maxAge: 86400000, 23 }); 24 25 res.json({ user: sanitizeUser(user) }); 26}); 27 28// Middleware 29async function authenticate(req, res, next) { 30 const sessionId = req.cookies.sessionId; 31 if (!sessionId) return res.status(401).json({ error: 'Not authenticated' }); 32 33 const session = await redis.get(`session:${sessionId}`); 34 if (!session) return res.status(401).json({ error: 'Session expired' }); 35 36 req.session = JSON.parse(session); 37 req.user = await db.user.findById(req.session.userId); 38 next(); 39}

Pros and Cons#

Pros: ✓ Easy to invalidate (delete from store) ✓ Session data stays on server ✓ Works well with traditional web apps ✓ Simple to understand Cons: ✗ Requires server-side storage ✗ Harder to scale (need shared session store) ✗ Not ideal for APIs consumed by mobile/SPAs

JWT (JSON Web Tokens)#

How It Works#

1. User logs in with credentials 2. Server creates signed JWT with user claims 3. Server sends JWT to client 4. Client stores JWT and sends in Authorization header 5. Server validates signature and extracts claims

Structure#

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. // Header eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4ifQ. // Payload SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c // Signature

Implementation#

1import jwt from 'jsonwebtoken'; 2 3// Login 4app.post('/login', async (req, res) => { 5 const { email, password } = req.body; 6 7 const user = await db.user.findByEmail(email); 8 if (!user || !await verifyPassword(password, user.passwordHash)) { 9 return res.status(401).json({ error: 'Invalid credentials' }); 10 } 11 12 const accessToken = jwt.sign( 13 { sub: user.id, email: user.email }, 14 process.env.JWT_SECRET, 15 { expiresIn: '15m' } 16 ); 17 18 const refreshToken = jwt.sign( 19 { sub: user.id, type: 'refresh' }, 20 process.env.JWT_REFRESH_SECRET, 21 { expiresIn: '7d' } 22 ); 23 24 // Store refresh token hash 25 await db.refreshToken.create({ 26 userId: user.id, 27 tokenHash: hash(refreshToken), 28 expiresAt: addDays(new Date(), 7), 29 }); 30 31 res.json({ accessToken, refreshToken }); 32}); 33 34// Middleware 35function authenticate(req, res, next) { 36 const authHeader = req.headers.authorization; 37 if (!authHeader?.startsWith('Bearer ')) { 38 return res.status(401).json({ error: 'No token provided' }); 39 } 40 41 const token = authHeader.slice(7); 42 43 try { 44 const payload = jwt.verify(token, process.env.JWT_SECRET); 45 req.user = { id: payload.sub, email: payload.email }; 46 next(); 47 } catch (err) { 48 return res.status(401).json({ error: 'Invalid token' }); 49 } 50}

Token Refresh#

1app.post('/refresh', async (req, res) => { 2 const { refreshToken } = req.body; 3 4 try { 5 const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET); 6 7 // Verify token exists in database 8 const storedToken = await db.refreshToken.findByUserAndHash( 9 payload.sub, 10 hash(refreshToken) 11 ); 12 13 if (!storedToken || storedToken.revoked) { 14 return res.status(401).json({ error: 'Invalid refresh token' }); 15 } 16 17 // Issue new tokens 18 const newAccessToken = jwt.sign( 19 { sub: payload.sub }, 20 process.env.JWT_SECRET, 21 { expiresIn: '15m' } 22 ); 23 24 res.json({ accessToken: newAccessToken }); 25 } catch (err) { 26 return res.status(401).json({ error: 'Invalid refresh token' }); 27 } 28});

Pros and Cons#

Pros: ✓ Stateless (no server-side storage for access tokens) ✓ Works well for APIs and microservices ✓ Self-contained (includes user data) ✓ Easy to scale Cons: ✗ Can't invalidate before expiry (without blocklist) ✗ Larger payload than session IDs ✗ Must handle token refresh ✗ Security-sensitive to implement correctly

OAuth 2.0 / OpenID Connect#

Common Flows#

Authorization Code Flow (recommended for web apps): 1. Redirect user to provider 2. User authenticates with provider 3. Provider redirects back with code 4. Exchange code for tokens 5. Use tokens to access resources PKCE (for SPAs and mobile): Same as above, but with code_verifier/code_challenge to prevent code interception attacks

Implementation with NextAuth.js#

1// pages/api/auth/[...nextauth].ts 2import NextAuth from 'next-auth'; 3import GoogleProvider from 'next-auth/providers/google'; 4import GitHubProvider from 'next-auth/providers/github'; 5 6export default NextAuth({ 7 providers: [ 8 GoogleProvider({ 9 clientId: process.env.GOOGLE_ID, 10 clientSecret: process.env.GOOGLE_SECRET, 11 }), 12 GitHubProvider({ 13 clientId: process.env.GITHUB_ID, 14 clientSecret: process.env.GITHUB_SECRET, 15 }), 16 ], 17 callbacks: { 18 async jwt({ token, account, user }) { 19 if (account) { 20 token.accessToken = account.access_token; 21 token.userId = user.id; 22 } 23 return token; 24 }, 25 async session({ session, token }) { 26 session.accessToken = token.accessToken; 27 session.userId = token.userId; 28 return session; 29 }, 30 }, 31});

Security Best Practices#

Password Handling#

1import bcrypt from 'bcrypt'; 2 3// Hashing 4const SALT_ROUNDS = 12; 5const passwordHash = await bcrypt.hash(password, SALT_ROUNDS); 6 7// Verification 8const isValid = await bcrypt.compare(password, passwordHash);

Token Storage (Client-Side)#

Access Tokens: ✓ In-memory (JavaScript variable) ✓ Short-lived (15 minutes) ✗ Not in localStorage (XSS vulnerable) Refresh Tokens: ✓ httpOnly cookie (prevents XSS) ✓ Secure flag (HTTPS only) ✓ SameSite attribute ✗ Not in localStorage

CSRF Protection#

1// With cookies, implement CSRF tokens 2import csrf from 'csurf'; 3 4const csrfProtection = csrf({ cookie: true }); 5 6app.get('/form', csrfProtection, (req, res) => { 7 res.render('form', { csrfToken: req.csrfToken() }); 8}); 9 10app.post('/submit', csrfProtection, (req, res) => { 11 // CSRF token validated automatically 12});

Rate Limiting#

1import rateLimit from 'express-rate-limit'; 2 3const loginLimiter = rateLimit({ 4 windowMs: 15 * 60 * 1000, // 15 minutes 5 max: 5, // 5 attempts per window 6 message: 'Too many login attempts', 7}); 8 9app.post('/login', loginLimiter, loginHandler);

Multi-Factor Authentication#

1import speakeasy from 'speakeasy'; 2import QRCode from 'qrcode'; 3 4// Generate secret 5app.post('/mfa/setup', authenticate, async (req, res) => { 6 const secret = speakeasy.generateSecret({ 7 name: `MyApp:${req.user.email}`, 8 }); 9 10 await db.user.update(req.user.id, { 11 mfaSecret: secret.base32, 12 mfaEnabled: false, 13 }); 14 15 const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url); 16 res.json({ qrCodeUrl, secret: secret.base32 }); 17}); 18 19// Verify and enable 20app.post('/mfa/verify', authenticate, async (req, res) => { 21 const { token } = req.body; 22 const user = await db.user.findById(req.user.id); 23 24 const verified = speakeasy.totp.verify({ 25 secret: user.mfaSecret, 26 encoding: 'base32', 27 token, 28 }); 29 30 if (verified) { 31 await db.user.update(req.user.id, { mfaEnabled: true }); 32 res.json({ success: true }); 33 } else { 34 res.status(400).json({ error: 'Invalid code' }); 35 } 36});

Choosing the Right Pattern#

Traditional Web App: → Sessions with cookies API for Mobile/SPA: → JWT with refresh tokens Third-Party Login: → OAuth 2.0 / OpenID Connect High Security: → Sessions + MFA → Short-lived tokens + refresh rotation

Conclusion#

Authentication is security-critical and complex. Use established libraries and patterns rather than rolling your own. Understand the trade-offs between stateful (sessions) and stateless (JWT) approaches, and choose based on your application's needs.

Security isn't a feature—it's a requirement. Implement defense in depth with rate limiting, proper password hashing, secure token storage, and multi-factor authentication.

Share this article

Help spread the word about Bootspring