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.