Choosing the Right Authentication Method: JWT, Sessions, or OAuth?
A comprehensive guide to choosing the right authentication method for your application with real-world examples, implementation strategies, and security considerations.
Table of Contents
- Introduction
- Authentication Overview
- Session-Based Authentication
- JWT (JSON Web Tokens)
- OAuth 2.0 & Social Login
- Side-by-Side Comparison
- Which Method to Use When
- Implementation Examples
- Security Best Practices
- Common Pitfalls & How to Avoid Them
- Hybrid Approaches
- Performance & Scalability
- Summary & Decision Framework
Introduction
Authentication is one of the most critical aspects of web application security. Choosing the right authentication method can affect your application's security, scalability, user experience, and development complexity.
This guide compares three popular authentication methods:
- Session-Based Authentication (Traditional server-side sessions)
- JWT (JSON Web Tokens) (Stateless token-based authentication)
- OAuth 2.0 (Delegated authorization and social login)
By the end of this guide, you'll understand when to use each method and how to implement them securely.
Authentication Overview
What is Authentication?
Authentication is the process of verifying a user's identity. It answers the question: "Who are you?"
What is Authorization?
Authorization determines what an authenticated user can do. It answers: "What are you allowed to do?"
Key Terms
- Credentials: Username/password, API keys, tokens
- Session: Server-side storage of user state
- Token: Self-contained piece of data proving identity
- Stateless: No server-side storage required
- Stateful: Server maintains session data
- Cookie: Small data stored in browser
- Bearer Token: Token sent in HTTP Authorization header
Session-Based Authentication
How It Works
- User submits login credentials
- Server validates credentials
- Server creates a session and stores it (in memory, Redis, database)
- Server sends session ID to client as a cookie
- Client sends session ID cookie with each request
- Server looks up session data using the session ID
┌─────────┐ ┌─────────┐
│ Browser │ │ Server │
└────┬────┘ └────┬────┘
│ │
│ POST /login │
│ {username, password} │
├─────────────────────────────>│
│ │
│ Validate credentials
│ Create session
│ Store in Redis/DB
│ │
│ Set-Cookie: sessionId=abc │
│<─────────────────────────────┤
│ │
│ GET /dashboard │
│ Cookie: sessionId=abc │
├─────────────────────────────>│
│ │
│ Lookup session in Redis
│ Verify session valid
│ │
│ 200 OK (dashboard data) │
│<─────────────────────────────┤
│ │
Pros of Session-Based Authentication
✅ Server Control
- Easy to invalidate sessions immediately
- Can revoke access without client action
- Logout is instant and reliable
✅ Security
- Sensitive data never leaves the server
- Session ID reveals nothing about the user
- Easy to implement session timeout
✅ Simple to Understand
- Traditional, well-understood approach
- Lots of libraries and frameworks support it
- Easy to debug and monitor
✅ Flexible Session Data
- Can store any amount of data server-side
- Can update session data without client knowing
- No size limits (unlike cookies/tokens)
Cons of Session-Based Authentication
❌ Server-Side Storage Required
- Need Redis, Memcached, or database
- Adds infrastructure complexity
- Requires session cleanup jobs
❌ Scaling Challenges
- Session data must be shared across servers
- Sticky sessions or centralized session store needed
- Can become a bottleneck at scale
❌ CSRF Vulnerability
- Cookies sent automatically by browser
- Requires CSRF protection tokens
- Additional security complexity
❌ Not Ideal for Mobile/API
- Cookie handling varies across platforms
- Native mobile apps handle cookies differently
- API clients may not support cookies well
Implementation Example: Express + Redis Sessions
// server.js
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const bcrypt = require('bcrypt');
const app = express();
// Create Redis client
const redisClient = createClient({
host: 'localhost',
port: 6379,
});
redisClient.connect().catch(console.error);
// Configure session middleware
app.use(
session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPS only
httpOnly: true, // No JavaScript access
maxAge: 1000 * 60 * 60 * 24, // 24 hours
sameSite: 'strict', // CSRF protection
},
})
);
// Login route
app.post('/login', async (req, res) => {
const { username, password } = req.body;
// Validate credentials (example)
const user = await db.findUserByUsername(username);
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Create session
req.session.userId = user.id;
req.session.username = user.username;
req.session.role = user.role;
res.json({ message: 'Login successful' });
});
// Protected route
app.get('/dashboard', requireAuth, async (req, res) => {
// req.session contains user data
const userData = await db.findUserById(req.session.userId);
res.json({ user: userData });
});
// Logout route
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: 'Logout failed' });
}
res.clearCookie('connect.sid'); // Clear session cookie
res.json({ message: 'Logged out successfully' });
});
});
// Auth middleware
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
}
app.listen(3000);
Redis Session Storage Structure:
Key: sess:abc123def456
Value: {
"userId": 42,
"username": "john_doe",
"role": "admin",
"cookie": {
"expires": "2026-02-04T00:00:00.000Z"
}
}
TTL: 86400 seconds
Best Practices for Sessions
-
Use Secure Cookies
- Set
secure: truefor HTTPS - Set
httpOnly: trueto prevent XSS - Set
sameSite: 'strict'for CSRF protection
- Set
-
Session Storage
- Use Redis for high-performance session storage
- Set appropriate TTL (time-to-live)
- Implement session cleanup for expired sessions
-
Session Regeneration
- Regenerate session ID after login
- Prevents session fixation attacks
-
Implement Session Timeout
- Absolute timeout (max session duration)
- Idle timeout (inactivity period)
JWT (JSON Web Tokens)
How It Works
- User submits login credentials
- Server validates credentials
- Server creates JWT with user claims
- Server signs JWT with secret key
- Server sends JWT to client
- Client stores JWT (localStorage, memory, cookie)
- Client sends JWT with each request (Authorization header)
- Server verifies JWT signature and extracts claims
┌─────────┐ ┌─────────┐
│ Browser │ │ Server │
└────┬────┘ └────┬────┘
│ │
│ POST /login │
│ {username, password} │
├─────────────────────────────>│
│ │
│ Validate credentials
│ Create JWT:
│ {userId: 42, role: "admin"}
│ Sign with secret
│ │
│ { token: "eyJhbG..." } │
│<─────────────────────────────┤
│ │
│ Store token in memory │
│ │
│ GET /dashboard │
│ Authorization: Bearer eyJ...│
├─────────────────────────────>│
│ │
│ Verify signature
│ Extract claims
│ Check expiration
│ │
│ 200 OK (dashboard data) │
│<─────────────────────────────┤
│ │
JWT Structure
A JWT consists of three parts separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjQyLCJ1c2VybmFtZSI6ImpvaG4iLCJpYXQiOjE2NDYwNzM2MDAsImV4cCI6MTY0NjE2MDAwMH0.4Adcj0mYmQ2fFQq7oBPR0VZh3KX6N39m8p8h0eEhZHo
Parts:
-
Header (algorithm and token type)
{ "alg": "HS256", "typ": "JWT" } -
Payload (claims/data)
{ "userId": 42, "username": "john", "role": "admin", "iat": 1646073600, // Issued at "exp": 1646160000 // Expiration } -
Signature (verifies integrity)
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret )
Pros of JWT Authentication
✅ Stateless
- No server-side session storage needed
- Easy to scale horizontally
- Each server can validate tokens independently
✅ Mobile & API Friendly
- Works seamlessly across platforms
- Simple to implement in mobile apps
- Native support in REST APIs
✅ Decentralized
- Microservices can validate tokens independently
- No need for shared session store
- Can work across different domains
✅ Contains User Data
- All user info in the token
- No database lookup needed
- Reduced server load
✅ Cross-Domain Support
- Works with CORS-enabled APIs
- Can be used across different domains
- Good for SPA (Single Page Applications)
Cons of JWT Authentication
❌ Cannot Be Revoked Easily
- Token valid until expiration
- Hard to invalidate before expiry
- Requires blacklist/allowlist for revocation
❌ Token Size
- Larger than session IDs
- Sent with every request
- Can impact bandwidth with large payloads
❌ XSS Vulnerability
- If stored in localStorage, vulnerable to XSS
- JavaScript can access the token
- Requires careful XSS prevention
❌ No Server Control
- Can't modify token data without reissue
- Can't force re-authentication
- User changes require new token
❌ Clock Synchronization
- Expiration relies on server time
- Clock skew can cause issues
- Need time sync across servers
Implementation Example: Express + JWT
// server.js
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const app = express();
app.use(express.json());
const JWT_SECRET = process.env.JWT_SECRET; // Store securely!
const JWT_EXPIRES_IN = '24h';
// Login route
app.post('/login', async (req, res) => {
const { username, password } = req.body;
// Validate credentials
const user = await db.findUserByUsername(username);
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Create JWT
const token = jwt.sign(
{
userId: user.id,
username: user.username,
role: user.role,
},
JWT_SECRET,
{
expiresIn: JWT_EXPIRES_IN,
issuer: 'myapp.com',
audience: 'myapp.com',
}
);
res.json({
token,
expiresIn: JWT_EXPIRES_IN,
user: {
id: user.id,
username: user.username,
role: user.role,
},
});
});
// Protected route
app.get('/dashboard', authenticateJWT, async (req, res) => {
// req.user contains decoded JWT payload
const userData = await db.findUserById(req.user.userId);
res.json({ user: userData });
});
// JWT Authentication Middleware
function authenticateJWT(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1]; // Bearer TOKEN
try {
const decoded = jwt.verify(token, JWT_SECRET, {
issuer: 'myapp.com',
audience: 'myapp.com',
});
req.user = decoded;
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
if (error.name === 'JsonWebTokenError') {
return res.status(401).json({ error: 'Invalid token' });
}
return res.status(500).json({ error: 'Token verification failed' });
}
}
// Refresh token endpoint (recommended)
app.post('/refresh', authenticateJWT, async (req, res) => {
// Verify user still exists and is active
const user = await db.findUserById(req.user.userId);
if (!user || !user.active) {
return res.status(401).json({ error: 'User no longer valid' });
}
// Issue new token
const newToken = jwt.sign(
{
userId: user.id,
username: user.username,
role: user.role,
},
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
);
res.json({ token: newToken });
});
app.listen(3000);
Client-Side Implementation (React Example):
// authService.js
class AuthService {
async login(username, password) {
const response = await fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (response.ok) {
// Store token in memory (most secure)
this.token = data.token;
// Or in localStorage (less secure, but survives page refresh)
// localStorage.setItem('token', data.token);
return data;
}
throw new Error(data.error);
}
getAuthHeader() {
return {
'Authorization': `Bearer ${this.token}`,
};
}
async fetchProtectedData() {
const response = await fetch('/dashboard', {
headers: this.getAuthHeader(),
});
if (response.status === 401) {
// Token expired, redirect to login
this.logout();
window.location.href = '/login';
}
return response.json();
}
logout() {
this.token = null;
// localStorage.removeItem('token');
}
}
export default new AuthService();
JWT Storage Options
1. Memory Storage (Most Secure)
✅ Pros:
- Protected from XSS attacks
- Cleared on page close/refresh
❌ Cons:
- Lost on page refresh
- Requires re-authentication frequently
2. localStorage (Common but Risky)
✅ Pros:
- Survives page refresh
- Easy to implement
❌ Cons:
- Vulnerable to XSS attacks
- Accessible by any JavaScript
3. httpOnly Cookie (Recommended)
✅ Pros:
- Protected from XSS
- Automatically sent with requests
- Survives page refresh
❌ Cons:
- Requires CSRF protection
- More complex to implement
4. sessionStorage
✅ Pros:
- Cleared when tab closes
- Separate per tab
❌ Cons:
- Vulnerable to XSS
- Lost on tab close
Best Practices for JWT
-
Keep Tokens Short-Lived
- Use 15 minutes to 1 hour expiration
- Implement refresh tokens for long sessions
-
Use Refresh Tokens
- Short-lived access tokens
- Long-lived refresh tokens (stored securely)
- Can be revoked in database
-
Validate Everything
- Check signature
- Verify expiration (exp)
- Validate issuer (iss) and audience (aud)
-
Secure the Secret
- Use strong, random secrets
- Rotate keys periodically
- Consider asymmetric keys (RS256)
-
Don't Store Sensitive Data
- JWT is base64-encoded, not encrypted
- Anyone can decode and read the payload
- Only store user ID and role
-
Implement Token Revocation
- Maintain a blacklist of revoked tokens
- Or use a whitelist approach with database
- Short expiration helps limit exposure
Refresh Token Pattern
// Dual-token approach
app.post('/login', async (req, res) => {
// ... validate credentials ...
// Short-lived access token (15 minutes)
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);
// Long-lived refresh token (7 days)
const refreshToken = jwt.sign(
{ userId: user.id, tokenType: 'refresh' },
REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' }
);
// Store refresh token in database (allows revocation)
await db.saveRefreshToken({
userId: user.id,
token: refreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
});
res.json({
accessToken,
refreshToken,
});
});
// Refresh endpoint
app.post('/refresh', async (req, res) => {
const { refreshToken } = req.body;
try {
// Verify refresh token
const decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET);
// Check if token exists in database
const storedToken = await db.findRefreshToken(refreshToken);
if (!storedToken || storedToken.revoked) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// Issue new access token
const accessToken = jwt.sign(
{ userId: decoded.userId },
ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken });
} catch (error) {
res.status(401).json({ error: 'Invalid refresh token' });
}
});
// Logout (revoke refresh token)
app.post('/logout', async (req, res) => {
const { refreshToken } = req.body;
await db.revokeRefreshToken(refreshToken);
res.json({ message: 'Logged out' });
});
OAuth 2.0 & Social Login
How It Works
OAuth 2.0 is an authorization framework that allows third-party applications to access user data without exposing credentials.
Authorization Code Flow (Most Common):
┌─────────┐ ┌─────────┐ ┌──────────┐
│ Browser │ │Your App │ │ Google │
└────┬────┘ └────┬────┘ └────┬─────┘
│ │ │
│ Click "Login w/Google"│ │
├───────────────────────>│ │
│ │ │
│ Redirect to Google │ │
│<───────────────────────┤ │
│ │ │
│ GET /authorize?client_id=... │
├────────────────────────────────────────────────>│
│ │ │
│ │ User logs in │
│ │ Grants consent │
│ │ │
│ Redirect with auth code │
│<────────────────────────────────────────────────┤
│ │ │
│ GET /callback?code=xyz│ │
├───────────────────────>│ │
│ │ │
│ │ POST /token │
│ │ {code, client_secret} │
│ ├───────────────────────>│
│ │ │
│ │ { access_token } │
│ │<───────────────────────┤
│ │ │
│ │ GET /userinfo │
│ │ Authorization: Bearer │
│ ├───────────────────────>│
│ │ │
│ │ { email, name, ... } │
│ │<───────────────────────┤
│ │ │
│ Set session/JWT │ │
│<───────────────────────┤ │
│ │ │
Pros of OAuth 2.0
✅ No Password Management
- Users don't need another password
- No password reset flows
- No password storage security concerns
✅ Faster Registration
- One-click sign up
- Pre-filled profile data
- Better conversion rates
✅ Trusted Providers
- Users trust Google, GitHub, etc.
- Better security than custom auth
- 2FA handled by provider
✅ Delegated Authorization
- Can request specific scopes (email, profile)
- User controls data sharing
- Can revoke access anytime
✅ Reduced Liability
- Provider handles authentication security
- No user credentials stored
- Compliance easier (GDPR, etc.)
Cons of OAuth 2.0
❌ Dependency on Third Party
- Service outage affects your app
- Provider changes can break your app
- Must comply with provider terms
❌ Complex Implementation
- Multiple steps in the flow
- Must handle errors and edge cases
- State management required
❌ Limited User Data
- Only get what provider shares
- Email might not be verified
- Profile data format varies
❌ Privacy Concerns
- Provider tracks user across sites
- Users may be wary of data sharing
- Some users don't have social accounts
❌ Multiple Provider Support
- Need separate integration for each
- More code to maintain
- Different APIs and data formats
Implementation Example: Google OAuth 2.0
Step 1: Get Google OAuth Credentials
- Go to Google Cloud Console
- Create a new project
- Enable Google+ API
- Create OAuth 2.0 credentials
- Add authorized redirect URI:
http://localhost:3000/auth/google/callback
Step 2: Install Passport.js
npm install passport passport-google-oauth20 express-session
Step 3: Configure OAuth Strategy
// server.js
const express = require('express');
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const session = require('express-session');
const app = express();
// Session configuration
app.use(
session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
})
);
// Initialize Passport
app.use(passport.initialize());
app.use(passport.session());
// Configure Google Strategy
passport.use(
new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: '/auth/google/callback',
},
async (accessToken, refreshToken, profile, done) => {
try {
// Find or create user in database
let user = await db.findUserByGoogleId(profile.id);
if (!user) {
// Create new user
user = await db.createUser({
googleId: profile.id,
email: profile.emails[0].value,
name: profile.displayName,
picture: profile.photos[0].value,
provider: 'google',
});
} else {
// Update user info
user = await db.updateUser(user.id, {
name: profile.displayName,
picture: profile.photos[0].value,
lastLogin: new Date(),
});
}
return done(null, user);
} catch (error) {
return done(error, null);
}
}
)
);
// Serialize user for session
passport.serializeUser((user, done) => {
done(null, user.id);
});
// Deserialize user from session
passport.deserializeUser(async (id, done) => {
try {
const user = await db.findUserById(id);
done(null, user);
} catch (error) {
done(error, null);
}
});
// Routes
app.get(
'/auth/google',
passport.authenticate('google', {
scope: ['profile', 'email'],
})
);
app.get(
'/auth/google/callback',
passport.authenticate('google', {
failureRedirect: '/login',
}),
(req, res) => {
// Successful authentication
res.redirect('/dashboard');
}
);
// Protected route
app.get('/dashboard', requireAuth, (req, res) => {
res.json({ user: req.user });
});
// Logout
app.get('/logout', (req, res) => {
req.logout((err) => {
if (err) return res.status(500).json({ error: 'Logout failed' });
res.redirect('/');
});
});
// Auth middleware
function requireAuth(req, res, next) {
if (req.isAuthenticated()) {
return next();
}
res.redirect('/login');
}
app.listen(3000);
Frontend Implementation:
<!-- login.html -->
<!DOCTYPE html>
<html>
<head>
<title>Login</title>
</head>
<body>
<h1>Login</h1>
<!-- Traditional login form -->
<form action="/login" method="POST">
<input type="text" name="username" placeholder="Username" required>
<input type="password" name="password" placeholder="Password" required>
<button type="submit">Login</button>
</form>
<hr>
<!-- OAuth login buttons -->
<a href="/auth/google">
<button>Login with Google</button>
</a>
<a href="/auth/github">
<button>Login with GitHub</button>
</a>
<a href="/auth/facebook">
<button>Login with Facebook</button>
</a>
</body>
</html>
Multiple OAuth Providers
// GitHub Strategy
passport.use(
new GitHubStrategy(
{
clientID: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackURL: '/auth/github/callback',
},
async (accessToken, refreshToken, profile, done) => {
// Similar to Google strategy
let user = await db.findOrCreateUser({
providerId: profile.id,
provider: 'github',
email: profile.emails[0].value,
username: profile.username,
});
done(null, user);
}
)
);
// GitHub routes
app.get('/auth/github', passport.authenticate('github', { scope: ['user:email'] }));
app.get('/auth/github/callback', passport.authenticate('github', { failureRedirect: '/login' }), (req, res) => {
res.redirect('/dashboard');
});
Database Schema for Multiple Providers:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255), -- NULL for OAuth users
name VARCHAR(255),
picture VARCHAR(500),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE oauth_accounts (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(50) NOT NULL, -- 'google', 'github', 'facebook'
provider_user_id VARCHAR(255) NOT NULL,
access_token TEXT,
refresh_token TEXT,
token_expires_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(provider, provider_user_id)
);
Best Practices for OAuth
- Always Verify State Parameter
- Prevents CSRF attacks
- Generate random state value
- Verify it matches on callback
app.get('/auth/google', (req, res) => {
const state = crypto.randomBytes(16).toString('hex');
req.session.oauthState = state;
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?` +
`client_id=${CLIENT_ID}&` +
`redirect_uri=${REDIRECT_URI}&` +
`response_type=code&` +
`scope=profile email&` +
`state=${state}`;
res.redirect(authUrl);
});
app.get('/auth/google/callback', (req, res) => {
const { state, code } = req.query;
// Verify state
if (state !== req.session.oauthState) {
return res.status(400).json({ error: 'Invalid state' });
}
// ... continue with token exchange ...
});
-
Handle Email Verification
- Check if email is verified by provider
- Some providers return unverified emails
- Implement your own verification if needed
-
Allow Account Linking
- Let users link multiple OAuth providers
- Match by verified email address
- Provide unlinking option
-
Store Minimal Token Data
- Don't store access tokens unless needed
- Refresh tokens should be encrypted
- Clean up expired tokens
-
Provide Fallback Authentication
- Don't rely solely on OAuth
- Offer email/password option
- Handle provider unavailability
Side-by-Side Comparison
| Feature | Sessions | JWT | OAuth 2.0 |
|---|---|---|---|
| Storage | Server-side (Redis/DB) | Client-side (token) | Server-side + Provider |
| Stateful/Stateless | Stateful | Stateless | Stateful |
| Scalability | Requires shared store | Easy to scale | Depends on implementation |
| Revocation | Instant | Difficult | Depends on strategy |
| Mobile Support | Limited | Excellent | Excellent |
| Microservices | Challenging | Easy | Moderate |
| Security | Very secure | Secure (if implemented correctly) | Very secure |
| Complexity | Low | Moderate | High |
| Token Size | Small (session ID) | Large (full payload) | Varies |
| CSRF Protection | Required | Not needed (if in header) | Required (if using cookies) |
| XSS Vulnerability | Low (httpOnly cookies) | High (if in localStorage) | Low (httpOnly cookies) |
| User Logout | Immediate | Delayed (until expiry) | Immediate |
| Cross-Domain | Difficult | Easy | Easy |
| Setup Complexity | Low | Low | High |
| Dependency | Redis/DB | None | Third-party provider |
Which Method to Use When
Use Session-Based Authentication When:
✅ Traditional web application (server-side rendering) ✅ You need immediate session revocation ✅ Building a monolithic application ✅ CSRF protection is easy to implement ✅ You have Redis or similar session store ✅ Security is top priority
Best For:
- Banking applications
- Admin panels
- E-commerce sites
- Traditional web apps with server-side rendering
Use JWT When:
✅ Building a REST API ✅ Mobile application backend ✅ Microservices architecture ✅ Need to scale horizontally ✅ Stateless authentication is preferred ✅ Cross-domain requests are common
Best For:
- REST APIs
- Mobile apps
- Single Page Applications (SPAs)
- Microservices
- Serverless architectures
Use OAuth 2.0 When:
✅ Want social login (Google, GitHub, etc.) ✅ Need to access user data from other services ✅ Building a B2C application ✅ Want to reduce friction in sign-up ✅ Don't want to manage passwords ✅ Need to delegate authorization
Best For:
- Consumer-facing applications
- Apps requiring social features
- Reducing sign-up friction
- Accessing third-party APIs (Google Drive, GitHub repos)
Decision Tree
Is this a public-facing consumer app?
│
├─ Yes → Do users expect social login?
│ │
│ ├─ Yes → Use OAuth 2.0 (+ JWT/Sessions for your own auth)
│ └─ No → Is it an API or mobile app?
│ │
│ ├─ Yes → Use JWT
│ └─ No → Use Sessions
│
└─ No → Is it an internal/enterprise app?
│
├─ Yes → Do you need microservices?
│ │
│ ├─ Yes → Use JWT
│ └─ No → Use Sessions
│
└─ Is it a mobile app?
│
├─ Yes → Use JWT
└─ No → Use Sessions
Implementation Examples
Scenario 1: E-commerce Website (Recommended: Sessions)
// Why Sessions?
// - Shopping cart state changes frequently
// - Need immediate logout (payment security)
// - Traditional web app with server-side rendering
// - CSRF protection already in place
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const csrf = require('csurf');
const app = express();
// Session with Redis
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true,
httpOnly: true,
maxAge: 1000 * 60 * 60 * 24, // 24 hours
},
}));
// CSRF protection
app.use(csrf());
// Shopping cart
app.post('/cart/add', requireAuth, csrfProtection, async (req, res) => {
const { productId, quantity } = req.body;
// Add to session cart
req.session.cart = req.session.cart || [];
req.session.cart.push({ productId, quantity });
res.json({ success: true });
});
// Checkout
app.post('/checkout', requireAuth, csrfProtection, async (req, res) => {
const cart = req.session.cart;
// Process payment
await processPayment(req.session.userId, cart);
// Clear cart
req.session.cart = [];
res.json({ success: true });
});
Scenario 2: Mobile App Backend (Recommended: JWT)
// Why JWT?
// - Mobile apps don't handle cookies well
// - Need stateless API for scaling
// - Multiple mobile platforms (iOS, Android)
// - Offline-first architecture
const express = require('express');
const jwt = require('jsonwebtoken');
const app = express();
// Login returns JWT
app.post('/api/v1/login', async (req, res) => {
const { email, password } = req.body;
const user = await authenticateUser(email, password);
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId: user.id, type: 'refresh' },
process.env.REFRESH_SECRET,
{ expiresIn: '7d' }
);
// Store refresh token in database
await db.saveRefreshToken(user.id, refreshToken);
res.json({ accessToken, refreshToken });
});
// Mobile client stores tokens securely
// iOS: Keychain
// Android: EncryptedSharedPreferences
// API endpoints verify JWT
app.get('/api/v1/profile', authenticateJWT, async (req, res) => {
const user = await db.findUserById(req.user.userId);
res.json({ user });
});
// Refresh token endpoint
app.post('/api/v1/refresh', async (req, res) => {
const { refreshToken } = req.body;
try {
const decoded = jwt.verify(refreshToken, process.env.REFRESH_SECRET);
// Verify token exists in database
const valid = await db.verifyRefreshToken(decoded.userId, refreshToken);
if (!valid) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// Issue new access token
const accessToken = jwt.sign(
{ userId: decoded.userId },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken });
} catch (error) {
res.status(401).json({ error: 'Invalid refresh token' });
}
});
Mobile Client (React Native Example):
// authService.js
import * as SecureStore from 'expo-secure-store';
class AuthService {
async login(email, password) {
const response = await fetch('https://api.myapp.com/v1/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const { accessToken, refreshToken } = await response.json();
// Store tokens securely
await SecureStore.setItemAsync('accessToken', accessToken);
await SecureStore.setItemAsync('refreshToken', refreshToken);
return true;
}
async getAccessToken() {
let accessToken = await SecureStore.getItemAsync('accessToken');
// Check if token is expired
const decoded = jwt_decode(accessToken);
const isExpired = decoded.exp < Date.now() / 1000;
if (isExpired) {
// Refresh token
accessToken = await this.refreshToken();
}
return accessToken;
}
async refreshToken() {
const refreshToken = await SecureStore.getItemAsync('refreshToken');
const response = await fetch('https://api.myapp.com/v1/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
const { accessToken } = await response.json();
await SecureStore.setItemAsync('accessToken', accessToken);
return accessToken;
}
async fetch(url, options = {}) {
const accessToken = await this.getAccessToken();
return fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${accessToken}`,
},
});
}
}
export default new AuthService();
Scenario 3: SaaS Application (Recommended: OAuth + JWT)
// Why OAuth + JWT?
// - Users expect social login
// - Need to integrate with Google Workspace, GitHub, etc.
// - Multiple services/APIs (microservices)
// - Both web and mobile clients
const express = require('express');
const passport = require('passport');
const jwt = require('jsonwebtoken');
const app = express();
// OAuth for social login
app.get('/auth/google', passport.authenticate('google', { scope: ['profile', 'email'] }));
app.get('/auth/google/callback',
passport.authenticate('google', { session: false }),
async (req, res) => {
// Create JWT after OAuth success
const accessToken = jwt.sign(
{ userId: req.user.id, role: req.user.role },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
const refreshToken = jwt.sign(
{ userId: req.user.id, type: 'refresh' },
process.env.REFRESH_SECRET,
{ expiresIn: '30d' }
);
// Store refresh token
await db.saveRefreshToken(req.user.id, refreshToken);
// Redirect with tokens
res.redirect(`/dashboard?token=${accessToken}&refresh=${refreshToken}`);
}
);
// Also support traditional email/password with JWT
app.post('/auth/register', async (req, res) => {
const { email, password } = req.body;
// Create user
const user = await createUser(email, password);
// Issue JWT
const accessToken = jwt.sign(
{ userId: user.id },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
res.json({ accessToken });
});
// Microservices can validate JWT independently
// Service A
app.get('/api/billing', authenticateJWT, async (req, res) => {
const bills = await billingService.getForUser(req.user.userId);
res.json({ bills });
});
// Service B (different server)
app.get('/api/analytics', authenticateJWT, async (req, res) => {
const stats = await analyticsService.getForUser(req.user.userId);
res.json({ stats });
});
Security Best Practices
General Security Rules
-
Always Use HTTPS
- Encrypt all traffic
- Prevent man-in-the-middle attacks
- Use HSTS header
-
Implement Rate Limiting
const rateLimit = require('express-rate-limit'); const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5, // 5 attempts message: 'Too many login attempts, please try again later', }); app.post('/login', loginLimiter, async (req, res) => { // ... login logic ... }); -
Hash Passwords Properly
const bcrypt = require('bcrypt'); // Hashing const saltRounds = 12; const passwordHash = await bcrypt.hash(password, saltRounds); // Verification const match = await bcrypt.compare(password, passwordHash); -
Validate and Sanitize Input
const { body, validationResult } = require('express-validator'); app.post('/login', body('email').isEmail().normalizeEmail(), body('password').isLength({ min: 8 }), async (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ errors: errors.array() }); } // ... login logic ... } ); -
Implement CSRF Protection
const csrf = require('csurf'); const csrfProtection = csrf({ cookie: true }); app.get('/form', csrfProtection, (req, res) => { res.render('form', { csrfToken: req.csrfToken() }); }); app.post('/form', csrfProtection, (req, res) => { // Process form }); -
Set Security Headers
const helmet = require('helmet'); app.use(helmet()); // Or manually: app.use((req, res, next) => { res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'DENY'); res.setHeader('X-XSS-Protection', '1; mode=block'); res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); next(); });
Session-Specific Security
-
Regenerate Session ID After Login
app.post('/login', async (req, res) => { // ... validate credentials ... // Regenerate session to prevent fixation req.session.regenerate((err) => { if (err) return res.status(500).json({ error: 'Login failed' }); req.session.userId = user.id; res.json({ success: true }); }); }); -
Implement Session Timeout
app.use((req, res, next) => { if (req.session.userId) { const now = Date.now(); const lastActivity = req.session.lastActivity || now; const timeout = 30 * 60 * 1000; // 30 minutes if (now - lastActivity > timeout) { req.session.destroy(); return res.status(401).json({ error: 'Session expired' }); } req.session.lastActivity = now; } next(); }); -
Use Secure Cookie Settings
app.use(session({ cookie: { secure: true, // HTTPS only httpOnly: true, // No JavaScript access sameSite: 'strict', // CSRF protection maxAge: 1000 * 60 * 60 * 24, // 24 hours }, }));
JWT-Specific Security
-
Use Strong Secrets
// Generate strong secret const crypto = require('crypto'); const secret = crypto.randomBytes(64).toString('hex'); // Store in environment variable // JWT_SECRET=your_strong_secret_here -
Implement Token Blacklist
// When user logs out, blacklist the token app.post('/logout', authenticateJWT, async (req, res) => { const token = req.headers.authorization.split(' ')[1]; // Add to Redis blacklist with TTL const decoded = jwt.decode(token); const ttl = decoded.exp - Math.floor(Date.now() / 1000); await redisClient.setex(`blacklist:${token}`, ttl, '1'); res.json({ message: 'Logged out' }); }); // Check blacklist in middleware async function authenticateJWT(req, res, next) { const token = req.headers.authorization?.split(' ')[1]; if (!token) { return res.status(401).json({ error: 'No token provided' }); } // Check if blacklisted const isBlacklisted = await redisClient.get(`blacklist:${token}`); if (isBlacklisted) { return res.status(401).json({ error: 'Token revoked' }); } // ... verify token ... } -
Rotate Signing Keys
// Use asymmetric keys (RS256) for better security const fs = require('fs'); const privateKey = fs.readFileSync('private.key'); const publicKey = fs.readFileSync('public.key'); // Sign with private key const token = jwt.sign(payload, privateKey, { algorithm: 'RS256', expiresIn: '1h', keyid: 'key-2026-02', // Key version }); // Verify with public key const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'], });
OAuth-Specific Security
-
Always Verify State Parameter
// Generate state const state = crypto.randomBytes(16).toString('hex'); req.session.oauthState = state; // Verify on callback if (req.query.state !== req.session.oauthState) { return res.status(400).json({ error: 'Invalid state' }); } delete req.session.oauthState; -
Use PKCE for Public Clients
// Proof Key for Code Exchange (for mobile/SPA) const crypto = require('crypto'); // Generate code verifier const codeVerifier = crypto.randomBytes(32).toString('base64url'); // Generate code challenge const codeChallenge = crypto .createHash('sha256') .update(codeVerifier) .digest('base64url'); // Send code_challenge in authorization request // Send code_verifier in token request -
Validate Redirect URIs
const allowedRedirectURIs = [ 'http://localhost:3000/callback', 'https://myapp.com/callback', ]; app.get('/authorize', (req, res) => { const redirectUri = req.query.redirect_uri; if (!allowedRedirectURIs.includes(redirectUri)) { return res.status(400).json({ error: 'Invalid redirect_uri' }); } // ... continue authorization ... });
Common Pitfalls & How to Avoid Them
1. Storing JWT in localStorage (XSS Vulnerability)
❌ Wrong:
// Vulnerable to XSS attacks
localStorage.setItem('token', token);
✅ Correct:
// Option 1: Store in memory (most secure, but lost on refresh)
let token = null;
// Option 2: httpOnly cookie (requires server-side change)
res.cookie('token', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
});
// Option 3: Encrypted cookie
const encryptedToken = encrypt(token);
res.cookie('token', encryptedToken);
2. Not Implementing Refresh Tokens
❌ Wrong:
// Long-lived access token (24 hours)
const token = jwt.sign(payload, secret, { expiresIn: '24h' });
// If compromised, valid for 24 hours!
✅ Correct:
// Short-lived access token (15 minutes)
const accessToken = jwt.sign(payload, secret, { expiresIn: '15m' });
// Long-lived refresh token (7 days, stored in database)
const refreshToken = jwt.sign(
{ userId: user.id, type: 'refresh' },
refreshSecret,
{ expiresIn: '7d' }
);
// Store refresh token (allows revocation)
await db.saveRefreshToken(user.id, refreshToken);
3. Weak Session Secrets
❌ Wrong:
app.use(session({
secret: 'myapp', // Weak, easily guessed
}));
✅ Correct:
// Generate strong secret
const crypto = require('crypto');
const secret = crypto.randomBytes(64).toString('hex');
console.log(secret); // Store in .env
// Use in production
app.use(session({
secret: process.env.SESSION_SECRET, // Strong, random secret
}));
4. Not Validating JWT Properly
❌ Wrong:
const decoded = jwt.decode(token); // Only decodes, doesn't verify!
✅ Correct:
try {
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'], // Specify allowed algorithms
issuer: 'myapp.com', // Validate issuer
audience: 'myapp.com', // Validate audience
});
// Check if user still exists
const user = await db.findUserById(decoded.userId);
if (!user || !user.active) {
throw new Error('User no longer valid');
}
req.user = decoded;
next();
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
5. Missing CSRF Protection with Sessions
❌ Wrong:
// No CSRF protection
app.post('/transfer-money', requireAuth, async (req, res) => {
await transferMoney(req.session.userId, req.body.to, req.body.amount);
res.json({ success: true });
});
✅ Correct:
const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });
// Generate token for forms
app.get('/transfer', csrfProtection, (req, res) => {
res.render('transfer', { csrfToken: req.csrfToken() });
});
// Validate token on submission
app.post('/transfer-money', requireAuth, csrfProtection, async (req, res) => {
await transferMoney(req.session.userId, req.body.to, req.body.amount);
res.json({ success: true });
});
6. Not Handling OAuth Errors
❌ Wrong:
app.get('/auth/google/callback',
passport.authenticate('google'),
(req, res) => {
res.redirect('/dashboard');
}
);
// If OAuth fails, user sees error page
✅ Correct:
app.get('/auth/google/callback', (req, res, next) => {
passport.authenticate('google', (err, user, info) => {
if (err) {
return res.redirect('/login?error=oauth_failed');
}
if (!user) {
return res.redirect('/login?error=no_user');
}
req.logIn(user, (err) => {
if (err) {
return res.redirect('/login?error=login_failed');
}
return res.redirect('/dashboard');
});
})(req, res, next);
});
7. Not Rotating Secrets/Keys
✅ Implement Key Rotation:
// Support multiple keys during rotation
const currentKey = process.env.JWT_SECRET_CURRENT;
const previousKey = process.env.JWT_SECRET_PREVIOUS;
function verifyToken(token) {
try {
// Try current key first
return jwt.verify(token, currentKey);
} catch (error) {
// Fallback to previous key
try {
return jwt.verify(token, previousKey);
} catch (fallbackError) {
throw new Error('Invalid token');
}
}
}
// Rotate keys every 90 days
// 1. Generate new key
// 2. Set as JWT_SECRET_CURRENT
// 3. Move old current to JWT_SECRET_PREVIOUS
// 4. Remove old previous
Hybrid Approaches
Combining Multiple Methods
Many real-world applications use a combination of authentication methods:
1. OAuth for Sign-up + Sessions for Auth
// User signs up with Google OAuth
app.get('/auth/google/callback',
passport.authenticate('google', { session: true }), // Use sessions
(req, res) => {
// req.user populated
// Session created automatically
res.redirect('/dashboard');
}
);
// Subsequent requests use session-based auth
app.get('/api/data', requireAuth, (req, res) => {
// req.user from session
res.json({ data: 'protected' });
});
2. OAuth + JWT for API
// OAuth for web, JWT for API/mobile
app.get('/auth/google/callback',
passport.authenticate('google', { session: false }),
(req, res) => {
// Create JWT after OAuth
const token = jwt.sign(
{ userId: req.user.id },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
// Web: Set cookie
res.cookie('token', token, { httpOnly: true });
res.redirect('/dashboard');
// Mobile: Return token in response
// res.json({ token });
}
);
3. Multi-Factor Authentication (MFA)
// First factor: Password
app.post('/login/password', async (req, res) => {
const { email, password } = req.body;
const user = await authenticateUser(email, password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
if (user.mfaEnabled) {
// Generate temporary token for MFA step
const mfaToken = jwt.sign(
{ userId: user.id, step: 'mfa' },
process.env.JWT_SECRET,
{ expiresIn: '5m' } // Short-lived
);
// Send 2FA code
await sendTOTPCode(user.email);
return res.json({
requiresMFA: true,
mfaToken,
});
}
// No MFA, issue regular token
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET);
res.json({ token });
});
// Second factor: TOTP code
app.post('/login/mfa', async (req, res) => {
const { mfaToken, code } = req.body;
try {
const decoded = jwt.verify(mfaToken, process.env.JWT_SECRET);
if (decoded.step !== 'mfa') {
return res.status(401).json({ error: 'Invalid token' });
}
const user = await db.findUserById(decoded.userId);
const valid = await verifyTOTP(user.totpSecret, code);
if (!valid) {
return res.status(401).json({ error: 'Invalid code' });
}
// Issue final token
const token = jwt.sign(
{ userId: user.id, mfaVerified: true },
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({ token });
} catch (error) {
res.status(401).json({ error: 'Invalid or expired token' });
}
});
API Gateway Pattern
For microservices, use an API gateway to handle authentication:
┌────────────┐
│ Client │
└─────┬──────┘
│ JWT Token
▼
┌────────────────┐
│ API Gateway │ ◄── Validates JWT, adds user context
└────────┬───────┘
│
┌────┴────┐
▼ ▼
┌────────┐ ┌────────┐
│Service │ │Service │ ◄── Trust gateway, no auth needed
│ A │ │ B │
└────────┘ └────────┘
// API Gateway (validates JWT)
app.use('/api/*', async (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Add user context to headers for downstream services
req.headers['x-user-id'] = decoded.userId;
req.headers['x-user-role'] = decoded.role;
// Forward request to appropriate service
proxy(req, res);
} catch (error) {
res.status(401).json({ error: 'Unauthorized' });
}
});
// Service A (trusts gateway)
app.get('/billing', (req, res) => {
const userId = req.headers['x-user-id'];
// No auth needed, gateway already validated
const bills = getBillingForUser(userId);
res.json({ bills });
});
Performance & Scalability
Session-Based Performance
Challenge: Sessions require server-side lookup on every request.
Solutions:
-
Use Redis for Session Storage
// Fast, in-memory storage const RedisStore = require('connect-redis').default; const redisClient = createClient({ url: process.env.REDIS_URL, socket: { reconnectStrategy: (retries) => Math.min(retries * 50, 500), }, }); -
Implement Session Caching
// Cache user data to avoid DB lookups const cache = new Map(); app.use(async (req, res, next) => { if (req.session.userId) { let user = cache.get(req.session.userId); if (!user) { user = await db.findUserById(req.session.userId); cache.set(req.session.userId, user); } req.user = user; } next(); }); -
Use Sticky Sessions (for multi-server setups)
# Nginx configuration upstream backend { ip_hash; # Same client always goes to same server server backend1.example.com; server backend2.example.com; }
Benchmarks:
- Session lookup in Redis: ~1ms
- Session lookup in PostgreSQL: ~10-50ms
- In-memory cache hit: ~0.1ms
JWT Performance
Challenge: JWT verification and decoding on every request.
Solutions:
-
Cache Decoded JWTs (for duration of request)
// Don't decode multiple times per request const cache = new Map(); function authenticateJWT(req, res, next) { const token = req.headers.authorization?.split(' ')[1]; let decoded = cache.get(token); if (!decoded) { decoded = jwt.verify(token, process.env.JWT_SECRET); cache.set(token, decoded); } req.user = decoded; next(); } -
Use Asymmetric Keys (RS256 for microservices)
// Each service can verify with public key // No need for shared secret const publicKey = fs.readFileSync('public.key'); jwt.verify(token, publicKey, { algorithms: ['RS256'] }); -
Keep Payload Small
// ❌ Bad: Large payload const token = jwt.sign({ userId: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, address: user.address, permissions: [...], // Large array }, secret); // ✅ Good: Minimal payload const token = jwt.sign({ sub: user.id, // Standard 'subject' claim role: user.role, }, secret);
Benchmarks:
- HS256 verification: ~0.1ms
- RS256 verification: ~0.5ms
- Decoding (no verification): ~0.01ms
Scaling Comparison
| Metric | Sessions | JWT |
|---|---|---|
| Server Overhead | High (DB/Redis queries) | Low (CPU for verification) |
| Network Overhead | Low (small cookie) | Medium (large token) |
| Database Load | High (session lookups) | Low (no lookups) |
| Horizontal Scaling | Requires shared session store | Easy (stateless) |
| Memory Usage | Server (session store) | Client (token storage) |
| Concurrent Users | Limited by session store | Limited by CPU |
Recommendation:
- < 10k users: Either works fine
- 10k-100k users: JWT scales better
- > 100k users: JWT with microservices architecture
Summary & Decision Framework
Quick Reference Guide
Choose Sessions if:
- Traditional web app (server-side rendering)
- Need immediate logout/revocation
- Security is critical (banking, payments)
- Small to medium application
- Monolithic architecture
Choose JWT if:
- Building REST API or microservices
- Mobile application backend
- Need horizontal scalability
- Stateless architecture preferred
- Cross-domain authentication
Choose OAuth if:
- Want social login (Google, GitHub, etc.)
- Need to access third-party APIs
- B2C consumer application
- Want to reduce sign-up friction
- Don't want password management burden
Recommended Combinations
| Application Type | Recommended Auth |
|---|---|
| Traditional Web App | Sessions + CSRF |
| SPA (React, Vue) | JWT (httpOnly cookie) + Refresh tokens |
| Mobile App | JWT + Refresh tokens |
| REST API | JWT (Authorization header) |
| Microservices | JWT (RS256) + API Gateway |
| Consumer App | OAuth (social) + JWT |
| Enterprise App | Sessions + OAuth (SSO) |
| Real-time App (WebSocket) | JWT for handshake, Session for connection |
Security Checklist
Before deploying any authentication system:
- All traffic over HTTPS
- Passwords hashed with bcrypt (12+ rounds)
- Rate limiting on login endpoints
- CSRF protection (if using cookies)
- Secure cookie flags (httpOnly, secure, sameSite)
- XSS prevention (CSP headers, input sanitization)
- SQL injection prevention (parameterized queries)
- Session timeout implemented
- Token expiration configured
- Refresh token rotation
- Token revocation strategy
- Security headers (Helmet.js)
- Secrets stored in environment variables
- Regular security audits
- Monitoring and logging
Final Thoughts
There's no one-size-fits-all authentication solution. The best choice depends on:
- Application architecture (monolith vs microservices)
- Client types (web, mobile, API)
- Scalability requirements (current and future)
- Security requirements (industry, compliance)
- User experience (social login expectations)
- Development resources (time and expertise)
Most modern applications benefit from a hybrid approach:
- OAuth for user-facing sign-up
- JWT for API authentication
- Sessions for traditional web routes
- Refresh tokens for long-lived access
The key is understanding the trade-offs and implementing each method securely.
🎉 You now have a comprehensive understanding of authentication methods! Choose wisely based on your specific needs.
Guide created: February 2026
Topics: Authentication, JWT, Sessions, OAuth 2.0, Security Best Practices