Authentication
Proving who you are - JWT tokens, sessions, and building secure login flows.
The Bouncer Problem#
A bouncer at a club has one job: verify you're allowed in. They check your ID, compare it to a list, and let you in or turn you away.
Authentication is your API's bouncer. It answers one question: "Who are you?"
Related but different:
- Authentication - Who are you? (login)
- Authorization - What can you do? (permissions)
Two Approaches: Tokens vs Sessions#
Sessions (Server-Side)#
The traditional approach. Server remembers who's logged in:
1. User logs in with email/password
2. Server creates a session record, stores it (memory, Redis, DB)
3. Server sends session ID as a cookie
4. Browser automatically sends cookie with every request
5. Server looks up session ID to identify user
Pros: Easy to revoke, server controls everything Cons: Requires server storage, harder to scale, not great for APIs
JWT Tokens (Stateless)#
Modern approach for APIs. Token contains user info:
1. User logs in with email/password
2. Server creates JWT containing user data, signs it with secret key
3. Server sends JWT to client
4. Client stores JWT and sends it in Authorization header
5. Server verifies JWT signature, trusts the data inside
Pros: Stateless (no server storage), scales easily, works for APIs Cons: Can't easily revoke, tokens can get large
Which to Choose?
For APIs (especially with mobile/SPA clients): JWT For traditional web apps with server-rendered pages: Sessions Many apps use both: JWT for API, sessions for admin panel
JWT Deep Dive#
A JWT has three parts:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMyIsImVtYWlsIjoiam9obkBleGFtcGxlLmNvbSIsImlhdCI6MTYwMDAwMDAwMH0.signature
└─────────── Header ───────────┘└──────────────── Payload ─────────────────────────────────────────────┘└─ Signature ─┘
- Header: Algorithm and token type (base64)
- Payload: Your data - user ID, email, expiration (base64)
- Signature: Proves the token wasn't tampered with
Creating JWTs#
import jwt from 'jsonwebtoken';
import { config } from '../config/index.js';
export function generateAccessToken(user) {
return jwt.sign(
{
id: user._id,
email: user.email,
role: user.role,
},
config.jwt.secret,
{ expiresIn: '15m' } // Short-lived
);
}
export function generateRefreshToken(user) {
return jwt.sign(
{ id: user._id },
config.jwt.refreshSecret,
{ expiresIn: '7d' } // Longer-lived
);
}
Verifying JWTs#
export function verifyAccessToken(token) {
try {
return jwt.verify(token, config.jwt.secret);
} catch (error) {
if (error.name === 'TokenExpiredError') {
throw new UnauthorizedError('Token expired');
}
throw new UnauthorizedError('Invalid token');
}
}
Complete Auth Flow#
Registration#
// src/services/auth.js
import { User } from '../models/User.js';
import { hashPassword, generateAccessToken, generateRefreshToken } from '../utils/auth.js';
import { ConflictError } from '../utils/errors.js';
export async function register(data) {
// Check if email exists
const existing = await User.findOne({ email: data.email.toLowerCase() });
if (existing) {
throw new ConflictError('Email already registered');
}
// Create user (password hashed by model hook)
const user = await User.create({
email: data.email.toLowerCase(),
password: data.password,
name: data.name,
});
// Generate tokens
const accessToken = generateAccessToken(user);
const refreshToken = generateRefreshToken(user);
// Store refresh token hash in database (for revocation)
user.refreshTokenHash = hashToken(refreshToken);
await user.save();
return {
user: { id: user._id, email: user.email, name: user.name },
tokens: { accessToken, refreshToken },
};
}
Login#
export async function login(email, password) {
// Find user with password field
const user = await User.findOne({ email: email.toLowerCase() })
.select('+password');
if (!user) {
throw new UnauthorizedError('Invalid credentials');
}
// Check password
const isValid = await user.comparePassword(password);
if (!isValid) {
throw new UnauthorizedError('Invalid credentials');
}
// Update last login
user.lastLogin = new Date();
// Generate tokens
const accessToken = generateAccessToken(user);
const refreshToken = generateRefreshToken(user);
user.refreshTokenHash = hashToken(refreshToken);
await user.save();
return {
user: { id: user._id, email: user.email, name: user.name },
tokens: { accessToken, refreshToken },
};
}
Token Refresh#
export async function refreshTokens(refreshToken) {
// Verify the refresh token
let payload;
try {
payload = jwt.verify(refreshToken, config.jwt.refreshSecret);
} catch {
throw new UnauthorizedError('Invalid refresh token');
}
// Find user and verify token hash
const user = await User.findById(payload.id);
if (!user) {
throw new UnauthorizedError('User not found');
}
const tokenHash = hashToken(refreshToken);
if (user.refreshTokenHash !== tokenHash) {
throw new UnauthorizedError('Token revoked');
}
// Generate new tokens
const newAccessToken = generateAccessToken(user);
const newRefreshToken = generateRefreshToken(user);
user.refreshTokenHash = hashToken(newRefreshToken);
await user.save();
return {
accessToken: newAccessToken,
refreshToken: newRefreshToken,
};
}
Logout#
export async function logout(userId) {
// Clear refresh token - this revokes it
await User.findByIdAndUpdate(userId, {
refreshTokenHash: null,
});
}
Auth Middleware#
// src/middleware/auth.js
import { verifyAccessToken } from '../utils/auth.js';
import { UnauthorizedError } from '../utils/errors.js';
export function authenticate(req, res, next) {
// Get token from header
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedError('No token provided');
}
const token = authHeader.split(' ')[1];
// Verify and decode
const payload = verifyAccessToken(token);
// Attach user to request
req.user = payload;
next();
}
// Optional auth - doesn't fail if no token
export function optionalAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return next();
}
try {
const token = authHeader.split(' ')[1];
req.user = verifyAccessToken(token);
} catch {
// Invalid token - continue without user
}
next();
}
Authorization Middleware#
// src/middleware/authorize.js
import { ForbiddenError } from '../utils/errors.js';
export function requireRole(...roles) {
return (req, res, next) => {
if (!req.user) {
throw new UnauthorizedError('Authentication required');
}
if (!roles.includes(req.user.role)) {
throw new ForbiddenError('Insufficient permissions');
}
next();
};
}
// Usage
router.delete('/users/:id',
authenticate,
requireRole('admin'),
userController.remove
);
Routes#
// src/routes/auth.js
import { Router } from 'express';
import * as authController from '../controllers/auth.js';
import { authenticate } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
import { registerSchema, loginSchema, refreshSchema } from '../validators/auth.js';
const router = Router();
router.post('/register', validate(registerSchema), authController.register);
router.post('/login', validate(loginSchema), authController.login);
router.post('/refresh', validate(refreshSchema), authController.refresh);
router.post('/logout', authenticate, authController.logout);
router.get('/me', authenticate, authController.me);
export default router;
Controller#
// src/controllers/auth.js
import * as authService from '../services/auth.js';
import { success, created, noContent } from '../utils/response.js';
export async function register(req, res) {
const result = await authService.register(req.body);
created(res, result);
}
export async function login(req, res) {
const { email, password } = req.body;
const result = await authService.login(email, password);
success(res, result);
}
export async function refresh(req, res) {
const { refreshToken } = req.body;
const tokens = await authService.refreshTokens(refreshToken);
success(res, tokens);
}
export async function logout(req, res) {
await authService.logout(req.user.id);
noContent(res);
}
export async function me(req, res) {
const user = await User.findById(req.user.id).select('-password');
success(res, user);
}
Security Best Practices#
Token Storage (Client-Side)#
// Web: Store in memory or httpOnly cookie
// NEVER localStorage (XSS vulnerable)
// Mobile: Secure storage (Keychain/Keystore)
Token Expiration#
// Access token: Short (15 min - 1 hour)
// Refresh token: Longer (7 days - 30 days)
// Short access tokens limit damage if stolen
// Refresh tokens let users stay logged in
Refresh Token Rotation#
// Issue new refresh token on each refresh
// Invalidate old one
// If old one is used, revoke all tokens (possible theft)
Rate Limiting#
import rateLimit from 'express-rate-limit';
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts
message: 'Too many login attempts, try again later',
});
router.post('/login', authLimiter, validate(loginSchema), authController.login);
Password Requirements#
const passwordSchema = z.string()
.min(8, 'At least 8 characters')
.regex(/[A-Z]/, 'Must contain uppercase')
.regex(/[a-z]/, 'Must contain lowercase')
.regex(/[0-9]/, 'Must contain number');
Key Takeaways#
- JWT for APIs - Stateless, scalable, good for mobile/SPA
- Two token system - Short access tokens, long refresh tokens
- Store refresh token hash - Enables revocation
- Rotate refresh tokens - Issue new one on each refresh
- Rate limit auth endpoints - Prevent brute force
- Separate authentication and authorization - Different concerns
Never Roll Your Own Crypto
Use bcrypt for passwords, jwt library for tokens. Don't try to implement encryption yourself. Even experts get it wrong.
Ready to level up your skills?
Explore more guides and tutorials to deepen your understanding and become a better developer.