Secure authentication
Production-ready authentication patterns. These aren't the simplest implementations—they're the ones that won't get you sued.
Authentication architecture decision
Sessions vs JWTs
Use sessions when:
- Server-rendered application
- Need immediate logout/revocation
- Single domain
- Simpler to implement correctly
Use JWTs when:
- Multiple services need to verify auth
- Stateless architecture required
- Mobile app + API
- Third-party integrations
Common mistake: Using JWTs because a tutorial did, then storing them in localStorage (XSS vulnerable) and having no revocation strategy.
Session-based authentication
Complete Express.js implementation
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const app = express();
// Redis client for session storage
const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.connect();
// Session configuration
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET, // At least 32 random bytes
name: 'sessionId', // Don't use default 'connect.sid'
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
httpOnly: true, // Not accessible via JavaScript
sameSite: 'lax', // CSRF protection
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
// Rate limiting for auth endpoints
const loginAttempts = new Map();
function checkRateLimit(ip) {
const attempts = loginAttempts.get(ip) || { count: 0, resetAt: Date.now() + 900000 };
if (Date.now() > attempts.resetAt) {
attempts.count = 0;
attempts.resetAt = Date.now() + 900000; // 15 minute window
}
if (attempts.count >= 5) {
return false;
}
attempts.count++;
loginAttempts.set(ip, attempts);
return true;
}
// Registration
app.post('/auth/register', async (req, res) => {
const { email, password } = req.body;
// Validate input
if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' });
}
if (password.length < 12) {
return res.status(400).json({ error: 'Password must be at least 12 characters' });
}
// Check if user exists
const existingUser = await db.query(
'SELECT id FROM users WHERE email = $1',
[email.toLowerCase()]
);
if (existingUser.rows.length > 0) {
// Don't reveal if email exists - use same message/timing
return res.status(400).json({ error: 'Registration failed' });
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Create user
const result = await db.query(
'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id',
[email.toLowerCase(), hashedPassword]
);
// Create session
req.session.userId = result.rows[0].id;
req.session.createdAt = Date.now();
res.json({ success: true });
});
// Login
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const clientIp = req.ip;
// Rate limiting
if (!checkRateLimit(clientIp)) {
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
}
// Validate input
if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' });
}
// Find user
const result = await db.query(
'SELECT id, password_hash FROM users WHERE email = $1',
[email.toLowerCase()]
);
if (result.rows.length === 0) {
// Timing attack prevention: still do bcrypt compare
await bcrypt.compare(password, '$2b$12$invalidhashtopreventtiming');
return res.status(401).json({ error: 'Invalid credentials' });
}
const user = result.rows[0];
// Verify password
const isValid = await bcrypt.compare(password, user.password_hash);
if (!isValid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Regenerate session to prevent fixation
req.session.regenerate((err) => {
if (err) {
return res.status(500).json({ error: 'Session error' });
}
req.session.userId = user.id;
req.session.createdAt = Date.now();
// Clear rate limit on successful login
loginAttempts.delete(clientIp);
res.json({ success: true });
});
});
// Logout
app.post('/auth/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: 'Logout failed' });
}
res.clearCookie('sessionId');
res.json({ success: true });
});
});
// Auth middleware
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
// Optional: Check session age
const maxAge = 24 * 60 * 60 * 1000; // 24 hours
if (Date.now() - req.session.createdAt > maxAge) {
req.session.destroy();
return res.status(401).json({ error: 'Session expired' });
}
next();
}
// Protected route
app.get('/api/profile', requireAuth, async (req, res) => {
const user = await db.query(
'SELECT id, email, created_at FROM users WHERE id = $1',
[req.session.userId]
);
res.json(user.rows[0]);
});
JWT authentication
Complete implementation with refresh tokens
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
// Token configuration
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET;
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET;
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';
// Store refresh tokens (use Redis in production)
const refreshTokens = new Map();
function generateAccessToken(userId) {
return jwt.sign(
{ userId, type: 'access' },
ACCESS_TOKEN_SECRET,
{ expiresIn: ACCESS_TOKEN_EXPIRY }
);
}
function generateRefreshToken(userId) {
const tokenId = crypto.randomBytes(32).toString('hex');
const token = jwt.sign(
{ userId, tokenId, type: 'refresh' },
REFRESH_TOKEN_SECRET,
{ expiresIn: REFRESH_TOKEN_EXPIRY }
);
// Store token ID for revocation
refreshTokens.set(tokenId, {
userId,
createdAt: Date.now(),
revoked: false
});
return token;
}
// Login - returns both tokens
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
// ... validation and password check ...
const accessToken = generateAccessToken(user.id);
const refreshToken = generateRefreshToken(user.id);
// Set refresh token as httpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
// Return access token in response body
res.json({ accessToken });
});
// Refresh endpoint
app.post('/auth/refresh', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
try {
const decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET);
// Check if token was revoked
const storedToken = refreshTokens.get(decoded.tokenId);
if (!storedToken || storedToken.revoked) {
return res.status(401).json({ error: 'Token revoked' });
}
// Generate new access token
const accessToken = generateAccessToken(decoded.userId);
res.json({ accessToken });
} catch (err) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
});
// Logout - revoke refresh token
app.post('/auth/logout', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (refreshToken) {
try {
const decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET);
const storedToken = refreshTokens.get(decoded.tokenId);
if (storedToken) {
storedToken.revoked = true;
}
} catch (err) {
// Token invalid, no action needed
}
}
res.clearCookie('refreshToken');
res.json({ success: true });
});
// Auth middleware for protected routes
function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Access token required' });
}
const token = authHeader.substring(7);
try {
const decoded = jwt.verify(token, ACCESS_TOKEN_SECRET);
if (decoded.type !== 'access') {
return res.status(401).json({ error: 'Invalid token type' });
}
req.userId = decoded.userId;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}
Frontend token handling
// auth.js - Frontend token management
class AuthManager {
constructor() {
this.accessToken = null;
}
async login(email, password) {
const response = await fetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // Important for cookies
body: JSON.stringify({ email, password })
});
if (!response.ok) {
throw new Error('Login failed');
}
const { accessToken } = await response.json();
this.accessToken = accessToken;
return true;
}
async refreshToken() {
const response = await fetch('/auth/refresh', {
method: 'POST',
credentials: 'include'
});
if (!response.ok) {
this.accessToken = null;
throw new Error('Session expired');
}
const { accessToken } = await response.json();
this.accessToken = accessToken;
return accessToken;
}
async fetchWithAuth(url, options = {}) {
if (!this.accessToken) {
throw new Error('Not authenticated');
}
const response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${this.accessToken}`
}
});
// If token expired, try to refresh and retry
if (response.status === 401) {
const body = await response.json();
if (body.code === 'TOKEN_EXPIRED') {
await this.refreshToken();
// Retry original request
return fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${this.accessToken}`
}
});
}
}
return response;
}
async logout() {
await fetch('/auth/logout', {
method: 'POST',
credentials: 'include'
});
this.accessToken = null;
}
}
export const auth = new AuthManager();
Password reset flow
Secure implementation
const crypto = require('crypto');
// Request password reset
app.post('/auth/forgot-password', async (req, res) => {
const { email } = req.body;
// Always return success to prevent email enumeration
res.json({ message: 'If an account exists, a reset link has been sent.' });
// Find user (async, after response)
const result = await db.query(
'SELECT id FROM users WHERE email = $1',
[email.toLowerCase()]
);
if (result.rows.length === 0) {
return; // User doesn't exist, but don't reveal that
}
const user = result.rows[0];
// Generate secure token
const token = crypto.randomBytes(32).toString('hex');
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const expiresAt = new Date(Date.now() + 3600000); // 1 hour
// Store hashed token (not plain token)
await db.query(
'INSERT INTO password_resets (user_id, token_hash, expires_at) VALUES ($1, $2, $3)',
[user.id, tokenHash, expiresAt]
);
// Send email with plain token
const resetUrl = `https://yourapp.com/reset-password?token=${token}`;
await sendEmail(email, 'Password Reset', `Reset your password: ${resetUrl}`);
});
// Reset password
app.post('/auth/reset-password', async (req, res) => {
const { token, newPassword } = req.body;
if (!token || !newPassword) {
return res.status(400).json({ error: 'Token and new password required' });
}
if (newPassword.length < 12) {
return res.status(400).json({ error: 'Password must be at least 12 characters' });
}
// Hash the provided token to compare with stored hash
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
// Find valid reset token
const result = await db.query(
`SELECT user_id FROM password_resets
WHERE token_hash = $1 AND expires_at > NOW() AND used = false`,
[tokenHash]
);
if (result.rows.length === 0) {
return res.status(400).json({ error: 'Invalid or expired token' });
}
const userId = result.rows[0].user_id;
// Hash new password
const hashedPassword = await bcrypt.hash(newPassword, 12);
// Update password and invalidate token
await db.query('UPDATE users SET password_hash = $1 WHERE id = $2', [hashedPassword, userId]);
await db.query('UPDATE password_resets SET used = true WHERE token_hash = $1', [tokenHash]);
// Invalidate all existing sessions for this user
await db.query('DELETE FROM sessions WHERE user_id = $1', [userId]);
res.json({ success: true });
});
OAuth integration (Google example)
Server-side flow (recommended)
const { OAuth2Client } = require('google-auth-library');
const oauth2Client = new OAuth2Client(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
process.env.GOOGLE_REDIRECT_URI
);
// Step 1: Redirect to Google
app.get('/auth/google', (req, res) => {
// Generate state for CSRF protection
const state = crypto.randomBytes(32).toString('hex');
req.session.oauthState = state;
const authUrl = oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: ['email', 'profile'],
state: state,
prompt: 'consent'
});
res.redirect(authUrl);
});
// Step 2: Handle callback
app.get('/auth/google/callback', async (req, res) => {
const { code, state } = req.query;
// Verify state to prevent CSRF
if (state !== req.session.oauthState) {
return res.status(400).send('Invalid state parameter');
}
delete req.session.oauthState;
try {
// Exchange code for tokens
const { tokens } = await oauth2Client.getToken(code);
oauth2Client.setCredentials(tokens);
// Get user info
const ticket = await oauth2Client.verifyIdToken({
idToken: tokens.id_token,
audience: process.env.GOOGLE_CLIENT_ID
});
const payload = ticket.getPayload();
const { sub: googleId, email, name, picture } = payload;
// Find or create user
let user = await db.query(
'SELECT id FROM users WHERE google_id = $1',
[googleId]
);
if (user.rows.length === 0) {
// Create new user
user = await db.query(
`INSERT INTO users (google_id, email, name, avatar_url)
VALUES ($1, $2, $3, $4) RETURNING id`,
[googleId, email, name, picture]
);
}
// Create session
req.session.regenerate((err) => {
if (err) {
return res.status(500).send('Session error');
}
req.session.userId = user.rows[0].id;
res.redirect('/dashboard');
});
} catch (error) {
console.error('OAuth error:', error);
res.status(400).send('Authentication failed');
}
});
Multi-factor authentication (TOTP)
Server implementation
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
// Enable MFA for user
app.post('/auth/mfa/enable', requireAuth, async (req, res) => {
// Generate secret
const secret = speakeasy.generateSecret({
name: `YourApp:${req.user.email}`,
issuer: 'YourApp'
});
// Store secret (encrypted) temporarily until verified
await db.query(
'UPDATE users SET mfa_secret_temp = $1 WHERE id = $2',
[encrypt(secret.base32), req.userId]
);
// Generate QR code
const qrCode = await QRCode.toDataURL(secret.otpauth_url);
res.json({
secret: secret.base32, // Show this as backup
qrCode: qrCode
});
});
// Verify and activate MFA
app.post('/auth/mfa/verify', requireAuth, async (req, res) => {
const { code } = req.body;
const result = await db.query(
'SELECT mfa_secret_temp FROM users WHERE id = $1',
[req.userId]
);
const secret = decrypt(result.rows[0].mfa_secret_temp);
const verified = speakeasy.totp.verify({
secret: secret,
encoding: 'base32',
token: code,
window: 1 // Allow 1 step tolerance
});
if (!verified) {
return res.status(400).json({ error: 'Invalid code' });
}
// Move secret from temp to permanent
await db.query(
'UPDATE users SET mfa_secret = mfa_secret_temp, mfa_secret_temp = NULL, mfa_enabled = true WHERE id = $1',
[req.userId]
);
res.json({ success: true });
});
// Login with MFA
app.post('/auth/login', async (req, res) => {
const { email, password, mfaCode } = req.body;
// ... verify email/password first ...
if (user.mfa_enabled) {
if (!mfaCode) {
return res.status(401).json({
error: 'MFA code required',
requiresMfa: true
});
}
const verified = speakeasy.totp.verify({
secret: decrypt(user.mfa_secret),
encoding: 'base32',
token: mfaCode,
window: 1
});
if (!verified) {
return res.status(401).json({ error: 'Invalid MFA code' });
}
}
// ... create session/token ...
});
Security considerations checklist
Password storage
- [ ] Using bcrypt/scrypt/Argon2 with cost factor 12+
- [ ] Never storing plain text passwords
- [ ] Never logging passwords
Session management
- [ ] Sessions stored server-side (not just in cookies)
- [ ] Session IDs are cryptographically random
- [ ] Sessions regenerated on login (prevent fixation)
- [ ] Sessions invalidated on logout
- [ ] Sessions have maximum lifetime
JWT security
- [ ] Short access token lifetime (15 min or less)
- [ ] Refresh tokens stored as httpOnly cookies
- [ ] Refresh token rotation implemented
- [ ] Token revocation mechanism exists
- [ ] Secrets are at least 256 bits
Rate limiting
- [ ] Login attempts limited per IP
- [ ] Account lockout after N failures
- [ ] Password reset requests limited
- [ ] MFA verification attempts limited
CSRF protection
- [ ] SameSite cookie attribute set
- [ ] CSRF tokens for state-changing operations
- [ ] OAuth state parameter verified
Information disclosure
- [ ] Same error messages for valid/invalid users
- [ ] Timing attacks mitigated
- [ ] No user enumeration via registration/reset