Modern Authentication: OAuth 2.0, JWT, and Session Management
Authentication is one of the most critical parts of any application. Get it wrong, and you have a security vulnerability. Get it right, and users can access your application securely. After implementing authentication for multiple production systems, I've learned the patterns that work.
Authentication vs. Authorization
Authentication
Who are you? - Verifying identity
// User provides credentials
const user = await authenticate(email, password);
// Returns user if credentials are valid
Authorization
What can you do? - Verifying permissions
// Check if user can perform action
if (user.role === 'admin') {
// Allow action
}
Session-Based Authentication
How It Works
- User logs in with credentials
- Server creates session (stored server-side)
- Server sends session ID (cookie)
- Client sends session ID with requests
- Server validates session ID
Implementation
const express = require('express');
const session = require('express-session');
const app = express();
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPS only
httpOnly: true, // Not accessible via JavaScript
maxAge: 3600000, // 1 hour
sameSite: 'strict' // CSRF protection
}
}));
// Login
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await authenticateUser(email, password);
if (user) {
req.session.userId = user.id;
req.session.role = user.role;
res.json({ success: true });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
// Protected route
app.get('/profile', requireAuth, (req, res) => {
// req.session.userId is available
res.json({ userId: req.session.userId });
});
// Logout
app.post('/logout', (req, res) => {
req.session.destroy();
res.json({ success: true });
});
Session Storage
In-memory (development):
// Simple, but doesn't scale
Redis (production):
const RedisStore = require('connect-redis')(session);
const redis = require('redis');
app.use(session({
store: new RedisStore({ client: redis.createClient() }),
secret: process.env.SESSION_SECRET,
// ... other options
}));
JWT (JSON Web Tokens)
How JWT Works
- User logs in
- Server creates JWT (signed, not encrypted)
- Server sends JWT to client
- Client stores JWT (localStorage, cookie)
- Client sends JWT with requests
- Server validates JWT signature
Creating JWTs
const jwt = require('jsonwebtoken');
function generateToken(user) {
return jwt.sign(
{
userId: user.id,
email: user.email,
role: user.role
},
process.env.JWT_SECRET,
{
expiresIn: '1h',
issuer: 'my-app',
audience: 'my-app-users'
}
);
}
Verifying JWTs
function verifyToken(token) {
try {
return jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'my-app',
audience: 'my-app-users'
});
} catch (error) {
throw new Error('Invalid token');
}
}
// Middleware
function requireAuth(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const decoded = verifyToken(token);
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({ error: 'Invalid token' });
}
}
Refresh Tokens
// Access token (short-lived)
const accessToken = jwt.sign(
{ userId: user.id },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
// Refresh token (long-lived)
const refreshToken = jwt.sign(
{ userId: user.id, type: 'refresh' },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' }
);
// Store refresh token in database
await db.query(
'INSERT INTO refresh_tokens (user_id, token) VALUES ($1, $2)',
[user.id, refreshToken]
);
// Refresh endpoint
app.post('/refresh', async (req, res) => {
const { refreshToken } = req.body;
// Verify refresh token
const decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
// Check if token exists in database
const token = await db.query(
'SELECT * FROM refresh_tokens WHERE token = $1',
[refreshToken]
);
if (!token.rows.length) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// Generate new access token
const newAccessToken = generateToken({ id: decoded.userId });
res.json({ accessToken: newAccessToken });
});
OAuth 2.0
OAuth 2.0 Flow
- User clicks "Login with Google"
- Redirect to Google authorization
- User authorizes
- Google redirects back with code
- Exchange code for access token
- Use access token to get user info
- Create/login user in your app
Implementation
const express = require('express');
const axios = require('axios');
// Step 1: Redirect to OAuth provider
app.get('/auth/google', (req, res) => {
const params = new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID,
redirect_uri: 'http://localhost:3000/auth/google/callback',
response_type: 'code',
scope: 'openid email profile'
});
res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`);
});
// Step 2: Handle callback
app.get('/auth/google/callback', async (req, res) => {
const { code } = req.query;
// Exchange code for token
const tokenResponse = await axios.post('https://oauth2.googleapis.com/token', {
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
code,
redirect_uri: 'http://localhost:3000/auth/google/callback',
grant_type: 'authorization_code'
});
const { access_token } = tokenResponse.data;
// Get user info
const userResponse = await axios.get('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: { Authorization: `Bearer ${access_token}` }
});
const { id, email, name } = userResponse.data;
// Create or login user
let user = await findUserByGoogleId(id);
if (!user) {
user = await createUser({ googleId: id, email, name });
}
// Create session or JWT
req.session.userId = user.id;
res.redirect('/dashboard');
});
When to Use What
Use Sessions When
- Server-side rendering - Traditional web apps
- Need to revoke immediately - Logout = destroy session
- Sensitive data - Tokens stored server-side
- Simple implementation - Easier to understand
Use JWT When
- Stateless APIs - Microservices, mobile apps
- Cross-domain - Multiple domains
- Scalability - No session storage needed
- Mobile apps - Native apps
Use OAuth When
- Third-party login - "Login with Google"
- API access - Accessing user's data from other services
- Enterprise SSO - Single sign-on
Security Best Practices
Password Hashing
const bcrypt = require('bcrypt');
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Verify password
const isValid = await bcrypt.compare(password, hashedPassword);
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'
});
app.post('/login', loginLimiter, loginHandler);
CSRF Protection
const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });
app.use(csrfProtection);
app.get('/form', (req, res) => {
res.render('form', { csrfToken: req.csrfToken() });
});
Real-World Example
Application: SaaS platform with web and mobile apps
Authentication Strategy:
- Web app: Sessions (traditional, secure)
- Mobile app: JWT with refresh tokens
- Third-party login: OAuth 2.0 (Google, GitHub)
- API: JWT for stateless authentication
Implementation:
// Unified authentication
async function authenticate(req) {
// Try session first (web)
if (req.session?.userId) {
return { userId: req.session.userId };
}
// Try JWT (mobile/API)
const token = req.headers.authorization?.split(' ')[1];
if (token) {
const decoded = verifyToken(token);
return { userId: decoded.userId };
}
throw new Error('Not authenticated');
}
Best Practices
- Hash passwords - Never store plain text
- Use HTTPS - Always in production
- Set expiration - Tokens and sessions should expire
- Validate input - Sanitize all user input
- Rate limit - Prevent brute force attacks
- Log security events - Monitor for attacks
- Use secure cookies - HttpOnly, Secure, SameSite
- Rotate secrets - Change secrets regularly
Conclusion
Authentication is complex, but with the right patterns:
- Sessions - Simple, secure, server-side
- JWT - Stateless, scalable, cross-domain
- OAuth - Third-party, standardized
Choose based on your needs, and always prioritize security over convenience.
Remember: Authentication is the foundation of security. Get it right, and everything else is easier.
What authentication challenges have you faced? What patterns have worked best for your applications?
Related Posts
Security Best Practices for Full-Stack Applications
Essential security practices every full-stack developer should know. From authentication to data protection, learn how to build secure applications.
Serverless Architecture: When to Use and When to Avoid
A practical guide to serverless architecture. Learn when serverless makes sense, its trade-offs, and how to build effective serverless applications.
AI Security and Privacy: Building Trustworthy AI Applications
Understand critical security and privacy considerations when building AI applications. Learn about prompt injection attacks, data privacy regulations, model safety, and how to build AI systems users can trust.
GraphQL vs REST: Making the Right API Choice in 2025
A comprehensive comparison of GraphQL and REST APIs in 2025. Learn when to use each approach, their trade-offs, and how to make the right decision for your project.
Microservices vs Monoliths: When to Choose What in 2024
A practical guide to choosing between microservices and monolithic architectures. Learn when each approach makes sense, common pitfalls, and how to make the right decision for your project.
API Design Principles: Creating Developer-Friendly REST APIs
Learn the principles and patterns for designing REST APIs that developers love to use. From URL structure to error handling, this guide covers it all.