Difficulty: Intermediate
Design a secure authentication system supporting email/password, OAuth, and multi-factor authentication.
A robust authentication system handles identity verification (authn) and access control (authz) through multiple methods.
Core flows: 1. Email/Password: Registration with email verification, login with bcrypt password comparison 2. OAuth 2.0: Delegate authentication to Google, GitHub etc. via authorization code flow 3. Session management: JWT (stateless) or server-side sessions (stateful) 4. MFA: TOTP (Time-based One-Time Password) as a second factor 5. Password reset: Secure token-based reset flow
Security considerations: bcrypt for hashing, HTTPS everywhere, CSRF protection, rate limiting on login, account lockout after failed attempts.
// JWT (Stateless) approach
// Server generates token with user info + expiry
const token = jwt.sign(
{ userId: user.id, role: user.role },
JWT_SECRET,
{ expiresIn: '15m' } // Short-lived access token
);
const refreshToken = jwt.sign(
{ userId: user.id },
REFRESH_SECRET,
{ expiresIn: '7d' } // Long-lived refresh token
);
// Client sends: Authorization: Bearer <token>
// Server verifies without DB lookup
// Session-Based (Stateful) approach
const sessionId = crypto.randomUUID();
await redis.set(`session:${sessionId}`, JSON.stringify({
userId: user.id,
role: user.role,
createdAt: Date.now()
}), 'EX', 86400);
// Set httpOnly cookie
res.cookie('sessionId', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 86400000
});
// Trade-offs:
// JWT: No server storage, works across services, can't revoke easily
// Sessions: Revocable, smaller payload, requires shared store
JWT is ideal for microservices and SPAs. Session-based auth is better when you need instant revocation. Many systems use both: JWT for API access, sessions for web app.
// Step 1: Redirect user to OAuth provider
app.get('/auth/google', (req, res) => {
const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: 'https://myapp.com/auth/google/callback',
response_type: 'code',
scope: 'openid email profile',
state: generateCSRFToken() // Prevent CSRF
});
res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`);
});
// Step 2: Handle callback with authorization code
app.get('/auth/google/callback', async (req, res) => {
const { code, state } = req.query;
verifyCSRFToken(state); // Validate state param
// Step 3: Exchange code for tokens
const { access_token, id_token } = await fetch(
'https://oauth2.googleapis.com/token',
{
method: 'POST',
body: JSON.stringify({
code,
client_id: GOOGLE_CLIENT_ID,
client_secret: GOOGLE_CLIENT_SECRET,
redirect_uri: 'https://myapp.com/auth/google/callback',
grant_type: 'authorization_code'
})
}
).then(r => r.json());
// Step 4: Get user info and create/link account
const profile = jwt.decode(id_token);
const user = await findOrCreateUser({
email: profile.email,
name: profile.name,
googleId: profile.sub
});
// Step 5: Issue our own tokens
const token = generateJWT(user);
res.redirect(`/app?token=${token}`);
});
The authorization code flow is the most secure OAuth grant type. The code is exchanged server-side, so the client secret never reaches the browser. Always validate the state parameter to prevent CSRF.
// Password hashing with bcrypt (cost factor 12)
const bcrypt = require('bcrypt');
async function register(email, password) {
// Validate password strength
if (password.length < 8) throw new Error('Password too short');
const passwordHash = await bcrypt.hash(password, 12);
const user = await db.users.create({
email,
passwordHash,
emailVerified: false
});
// Send verification email with token
const verifyToken = crypto.randomBytes(32).toString('hex');
await redis.set(`verify:${verifyToken}`, user.id, 'EX', 86400);
await sendEmail(email, `Verify: /verify?token=${verifyToken}`);
}
async function login(email, password, totpCode) {
const user = await db.users.findByEmail(email);
if (!user) throw new Error('Invalid credentials');
// Check account lockout
const attempts = await redis.get(`login_attempts:${user.id}`);
if (parseInt(attempts) >= 5) {
throw new Error('Account locked. Try again in 15 minutes.');
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
await redis.incr(`login_attempts:${user.id}`);
await redis.expire(`login_attempts:${user.id}`, 900);
throw new Error('Invalid credentials');
}
// Check MFA if enabled
if (user.mfaEnabled) {
const totp = require('totp-generator');
if (!verifyTOTP(user.mfaSecret, totpCode)) {
throw new Error('Invalid 2FA code');
}
}
await redis.del(`login_attempts:${user.id}`);
return generateJWT(user);
}
Bcrypt with cost factor 12 provides strong password hashing. Account lockout after 5 failed attempts prevents brute force. TOTP-based MFA adds a second factor that changes every 30 seconds.
JWT, OAuth 2.0, Session Management, Password Hashing, MFA