Introduction

Welcome to the third lesson of the "Multi-Factor Authentication (MFA) in Express" course! In this lesson, we will focus on integrating MFA into an Express application. Building on the foundational concepts from the previous lesson, we will explore the practical steps necessary to enhance the security of your application by implementing MFA. Let's dive in and see how we can secure our application with MFA! 🚀

Secure MFA Integration Patterns

Now that you're familiar with the core MFA functions like generateSecret, verifyToken, verifyCode, and generateBackupCodes, let's focus on integrating these functions securely into your Express application. Proper integration is crucial - even with well-implemented MFA functions, vulnerabilities can arise from insecure application design. We'll examine how to store MFA data securely, protect sensitive endpoints, and create a robust authentication flow.

Security Reminder:

  • Always encrypt MFA secrets at rest to prevent attackers from accessing them if your database is compromised.
  • Always hash backup codes before storing them, just like passwords, so they cannot be used if leaked.
  • Implement rate-limiting on all sensitive endpoints (such as /verify, /login, and /login/verify) to protect against brute-force attacks.
The Vulnerable Code

Let's examine a scenario where MFA is not properly integrated, leading to potential security vulnerabilities:

// Example of a vulnerable MFA setup
router.post('/verify', async (req, res) => {
  const { username, token } = req.body;
  const user = await User.findOne({ where: { username } });
  const isValid = authenticator.verify({ token, secret: user.mfaSecret });
  if (isValid) {
    user.mfaEnabled = true;
    await user.save();
    res.json({ success: true });
  } else {
    res.json({ success: false, error: 'Invalid token' });
  }
});

This code contains several critical vulnerabilities:

  • No authentication checks - anyone can access this endpoint
  • No validation that the user exists before accessing properties
  • No protection against brute force attacks
  • The endpoint updates user settings without proper authorization
Secure User Model Modifications

To support MFA securely, we need to modify our user model to store MFA-related data, such as secrets and backup codes. This step is crucial for managing MFA settings for each user.

// Update the user model to include MFA fields
export class User extends Model {
  declare mfaEnabled: boolean;
  declare mfaSecret: string | null; // Should be encrypted at rest
  declare backupCodes: string | null; // Should be a JSON string of hashed codes
}

In this model, we store:

  • mfaEnabled: Whether MFA is active for this user
  • mfaSecret: The secret key for TOTP generation (should be encrypted at rest)
  • backupCodes: JSON string of hashed backup codes for account recovery

Security Reminder:

  • Never store MFA secrets or backup codes in plaintext.
  • Use strong encryption for secrets and a secure hash function (e.g., bcrypt, Argon2) for backup codes.
Implementing MFA Setup

Let's create a secure route for setting up MFA, using the functions you already know:

// Route to set up MFA (requires authentication middleware)
// Add rate-limiting middleware here in production
router.post('/setup', authMiddleware, async (req, res) => {
  try {
    const user = await User.findByPk(req.user.id);
    if (!user) {
      return res.status(404).json({ success: false, error: 'User not found' });
    }
    
    // Generate MFA secret using our utility function
    const { secret, otpauth } = MFAUtil.generateSecret(user.username);
    
    // Encrypt the secret before storing
    const encryptedSecret = encryptSecret(secret);
    
    // Generate backup codes and hash them before storing
    const backupCodes = MFAUtil.generateBackupCodes();
    const hashedBackupCodes = backupCodes.map(code => hashBackupCode(code));
    
    // Set both mfaSecret and backupCodes before saving
    user.mfaSecret = encryptedSecret;
    user.mfaEnabled = false;
    user.backupCodes = JSON.stringify(hashedBackupCodes);
    await user.save();
    
    // Generate QR code
    const qrCodeUrl = await qrcode.toDataURL(otpauth);
    
    res.json({ 
      success: true, 
      qrCodeUrl, 
      backupCodes, // Return plain codes to user only at creation
      message: 'Scan the QR code with your authenticator app, then verify with a token'
    });
  } catch (error) {
    res.status(500).json({ success: false, error: 'Failed to set up MFA' });
  }
});

This secure implementation includes:

  • Authentication middleware to ensure only logged-in users can access it
  • Error handling for failed operations
  • Proper user verification
  • Temporary storage of the secret until verified
  • Encryption of secrets and hashing of backup codes
  • (In production) Add rate-limiting middleware to this endpoint
Verifying MFA Tokens Securely

Next, we'll create a secure route to verify the MFA token provided by the user:

// Add rate-limiting middleware here in production
router.post('/verify', authMiddleware, async (req, res) => {
  try {
    const { token } = req.body;
    if (!token) {
      return res.status(400).json({ success: false, error: 'Token is required' });
    }
    
    const user = await User.findByPk(req.user.id);
    if (!user || !user.mfaSecret) {
      return res.status(400).json({ success: false, error: 'MFA not set up' });
    }
    
    // Decrypt the secret before verification
    const decryptedSecret = decryptSecret(user.mfaSecret);
    
    // Verify the token
    const isValid = MFAUtil.verifyToken(token, decryptedSecret);
    
    if (isValid) {
      // Only now do we enable MFA for the user
      user.mfaEnabled = true;
      await user.save();
      
      // Generate a new session token or refresh existing session
      const sessionToken = generateSessionToken(user);
      
      return res.json({ 
        success: true, 
        message: 'MFA enabled successfully', 
        sessionToken 
      });
    } else {
      return res.status(401).json({ success: false, error: 'Invalid token' });
    }
  } catch (error) {
    return res.status(500).json({ success: false, error: 'Failed to verify token' });
  }
});

This implementation includes:

  • Input validation for the token
  • Authentication checks
  • Verification that MFA is set up
  • Proper error handling
  • Session regeneration after successful verification
  • Decryption of secrets before use
  • (In production) Add rate-limiting middleware to this endpoint
Implementing Login with MFA

When MFA is enabled, the login process becomes a two-step procedure. In this flow, a temporary token (often called a tempToken) is issued after the user successfully enters their username and password, but before they complete MFA verification.

A tempToken is a short-lived, signed token (such as a JWT or a random string stored server-side) that securely encodes the user's identity and the fact that they have passed the first authentication step. It is used to track the login session between the password and MFA steps, ensuring that only the correct user can proceed to MFA verification. Typically, a tempToken should expire quickly—commonly within 5 to 10 minutes—to minimize the risk of misuse.

// Add rate-limiting middleware here in production
router.post('/login', async (req, res) => {
  try {
    const { username, password } = req.body;
    const user = await User.findOne({ where: { username } });
    
    // Verify username and password
    if (!user || !verifyPassword(password, user.passwordHash)) {
      return res.status(401).json({ success: false, error: 'Invalid credentials' });
    }
    
    // Check if MFA is enabled
    if (user.mfaEnabled) {
      // Generate a temporary login token that will be used to complete login
      const tempToken = generateTempToken(user.id);
      
      return res.json({
        success: true,
        requireMFA: true,
        tempToken,
        message: 'Please enter your MFA code to complete login'
      });
    } else {
      // Regular login without MFA
      const sessionToken = generateSessionToken(user);
      return res.json({ success: true, sessionToken });
    }
  } catch (error) {
    return res.status(500).json({ success: false, error: 'Login failed' });
  }
});

// Second step for MFA verification
// Add rate-limiting middleware here in production
router.post('/login/verify', async (req, res) => {
  try {
    const { tempToken, mfaCode } = req.body;
    
    // Verify the temporary token
    const userId = verifyTempToken(tempToken);
    if (!userId) {
      return res.status(401).json({ success: false, error: 'Invalid session' });
    }
    
    const user = await User.findByPk(userId);
    if (!user || !user.mfaSecret) {
      return res.status(400).json({ success: false, error: 'User not found' });
    }
    
    // Decrypt the secret before verification
    const decryptedSecret = decryptSecret(user.mfaSecret);
    
    // Convert stored hashed backup codes to Set
    const hashedBackupCodes = user.backupCodes ? new Set(JSON.parse(user.backupCodes)) : new Set();
    
    // Verify using both token and backup codes (compare mfaCode to hashed codes)
    const isValid = MFAUtil.verifyCode(mfaCode, decryptedSecret, hashedBackupCodes);
    
    if (isValid) {
      // If a backup code was used, update the stored codes
      if (user.backupCodes) {
        user.backupCodes = JSON.stringify(Array.from(hashedBackupCodes));
        await user.save();
      }
      
      // Complete login
      const sessionToken = generateSessionToken(user);
      return res.json({ success: true, sessionToken });
    } else {
      return res.status(401).json({ success: false, error: 'Invalid MFA code' });
    }
  } catch (error) {
    return res.status(500).json({ success: false, error: 'Verification failed' });
  }
});

This two-step process ensures:

  • Users with MFA enabled must provide a valid MFA code
  • Temporary tokens control the flow between login steps
  • Both TOTP and backup codes are supported
  • Used backup codes are invalidated
  • Secrets are decrypted before use, and backup codes are always hashed
  • (In production) Add rate-limiting middleware to these endpoints
Conclusion and Next Steps

In this lesson, we explored the secure integration of Multi-Factor Authentication into an Express application. We examined potential vulnerabilities, modified the user model, and created secure routes for setting up and verifying MFA. These integration patterns prevent common security issues while providing a solid user experience. As you move on to the practice exercises, you'll have the opportunity to implement these features and reinforce your understanding. In the next lessons, we'll continue to build on these concepts, further strengthening your application's security. Keep up the great work! 🌟

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal