Design an Authentication System

Difficulty: Intermediate

Question

Design a secure authentication system supporting email/password, OAuth, and multi-factor authentication.

Answer

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.

Code examples

JWT vs Session-Based Auth

// 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.

OAuth 2.0 Authorization Code Flow

// 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 Security & MFA

// 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.

Key points

Concepts covered

JWT, OAuth 2.0, Session Management, Password Hashing, MFA