Skip to main content

Express Multi-factor Authentication

In today's digital landscape, password-based authentication alone isn't enough to secure user accounts effectively. Multi-factor authentication (MFA) adds additional layers of security by requiring users to verify their identity through multiple methods before gaining access to an application.

What is Multi-factor Authentication?

Multi-factor authentication requires users to provide two or more verification factors to gain access to a resource such as an application, online account, or VPN. Rather than just asking for a username and password, MFA requires additional verification methods such as:

  1. Knowledge - Something you know (password, PIN, security questions)
  2. Possession - Something you have (mobile device, security key, authentication app)
  3. Inherence - Something you are (biometrics like fingerprints or facial recognition)

By implementing MFA in your Express applications, you significantly enhance security even if one factor (like a password) becomes compromised.

Setting Up Multi-factor Authentication in Express

Let's walk through implementing a basic two-factor authentication (2FA) system in an Express application using Time-based One-Time Passwords (TOTP), which is the most common form of MFA.

Prerequisites

First, we need to install the necessary packages:

bash
npm install express express-session passport passport-local speakeasy qrcode
  • express-session: For session management
  • passport & passport-local: For basic authentication
  • speakeasy: For generating and verifying TOTP tokens
  • qrcode: For generating QR codes that users can scan with authenticator apps

Basic Setup

Let's start with our basic Express application setup:

javascript
const express = require('express');
const session = require('express-session');
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');

const app = express();

// Middleware setup
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(session({
secret: 'your-secret-key',
resave: false,
saveUninitialized: true,
cookie: { secure: process.env.NODE_ENV === 'production' }
}));
app.use(passport.initialize());
app.use(passport.session());

// Mock user database
const users = [];

// Start server
app.listen(3000, () => {
console.log('Server running on port 3000');
});

Step 1: Implement Basic Authentication

First, let's implement a basic username/password authentication system:

javascript
// Passport configuration
passport.use(new LocalStrategy(
(username, password, done) => {
const user = users.find(u => u.username === username);
if (!user) { return done(null, false, { message: 'Incorrect username.' }); }
if (user.password !== password) { return done(null, false, { message: 'Incorrect password.' }); }

return done(null, user);
}
));

passport.serializeUser((user, done) => {
done(null, user.id);
});

passport.deserializeUser((id, done) => {
const user = users.find(u => u.id === id);
done(null, user);
});

// Register route
app.post('/register', (req, res) => {
const { username, password } = req.body;

if (users.find(u => u.username === username)) {
return res.status(400).json({ message: 'Username already exists' });
}

const newUser = {
id: users.length + 1,
username,
password,
mfaEnabled: false,
mfaSecret: null
};

users.push(newUser);
res.status(201).json({ message: 'User registered successfully' });
});

// Login route (first factor)
app.post('/login',
passport.authenticate('local', { failureRedirect: '/login-failed' }),
(req, res) => {
if (req.user.mfaEnabled) {
// If MFA is enabled, don't fully authenticate yet
req.session.partiallyAuthenticated = true;
res.json({ message: 'Please enter your MFA code', requireMFA: true });
} else {
// No MFA required, fully authenticate
req.session.partiallyAuthenticated = false;
res.json({ message: 'Authentication successful' });
}
}
);

app.get('/login-failed', (req, res) => {
res.status(401).json({ message: 'Authentication failed' });
});

Step 2: Set Up MFA Enrollment

Now, let's add the ability for users to enable MFA:

javascript
// Generate MFA secret and setup QR code
app.get('/mfa/setup', (req, res) => {
if (!req.isAuthenticated()) {
return res.status(401).json({ message: 'Unauthorized' });
}

// Generate a new secret
const secret = speakeasy.generateSecret({
name: `MyApp:${req.user.username}`
});

// Save the secret to the user (temporarily until verified)
req.user.tempSecret = secret.base32;

// Generate QR code
QRCode.toDataURL(secret.otpauth_url, (err, imageData) => {
if (err) {
return res.status(500).json({ message: 'Error generating QR code' });
}

// Return the secret and QR code
res.json({
message: 'MFA setup initiated',
secret: secret.base32,
qrCode: imageData
});
});
});

// Verify and enable MFA
app.post('/mfa/verify', (req, res) => {
if (!req.isAuthenticated()) {
return res.status(401).json({ message: 'Unauthorized' });
}

const { token } = req.body;
const user = req.user;

// Verify the token against the secret
const verified = speakeasy.totp.verify({
secret: user.tempSecret,
encoding: 'base32',
token: token
});

if (verified) {
// If token is verified, enable MFA for the user
user.mfaSecret = user.tempSecret;
user.mfaEnabled = true;
user.tempSecret = null;

res.json({ message: 'MFA enabled successfully' });
} else {
res.status(400).json({ message: 'Invalid MFA token' });
}
});

Step 3: Implement MFA Verification During Login

Now we need to add the second factor verification during the login process:

javascript
// Verify MFA token during login
app.post('/mfa/validate', (req, res) => {
// Check if the user completed the first factor
if (!req.session.partiallyAuthenticated) {
return res.status(401).json({ message: 'Please login first' });
}

const { token } = req.body;
const user = req.user;

// Verify the token
const verified = speakeasy.totp.verify({
secret: user.mfaSecret,
encoding: 'base32',
token: token,
window: 1 // Allow 1 step before/after for clock drift
});

if (verified) {
// Complete authentication
req.session.partiallyAuthenticated = false;
res.json({ message: 'Authentication successful' });
} else {
res.status(400).json({ message: 'Invalid MFA token' });
}
});

// Middleware to check complete authentication
function ensureFullAuthentication(req, res, next) {
if (req.isAuthenticated() && !req.session.partiallyAuthenticated) {
return next();
}
res.status(401).json({ message: 'Full authentication required' });
}

// Protected route example
app.get('/protected', ensureFullAuthentication, (req, res) => {
res.json({ message: 'This is protected data!', user: req.user.username });
});

Step 4: Disabling MFA (Optional)

Users may need to disable MFA if they lose access to their authenticator device:

javascript
// Disable MFA (requires current password for security)
app.post('/mfa/disable', (req, res) => {
if (!req.isAuthenticated()) {
return res.status(401).json({ message: 'Unauthorized' });
}

const { password } = req.body;
const user = req.user;

// Verify password before disabling MFA
if (user.password !== password) {
return res.status(401).json({ message: 'Incorrect password' });
}

// Disable MFA
user.mfaEnabled = false;
user.mfaSecret = null;

res.json({ message: 'MFA disabled successfully' });
});

Practical Implementation Example

Let's look at a complete flow of how a user would interact with this MFA system:

User Registration

  1. User registers with username and password
  2. Initially, MFA is not enabled

Request:

http
POST /register
Content-Type: application/json

{
"username": "johnsmith",
"password": "securepassword123"
}

Response:

json
{
"message": "User registered successfully"
}

Setting Up MFA

  1. User logs in with username and password
  2. User visits MFA setup page
  3. User is shown a QR code to scan with authenticator app
  4. User enters the code from their authenticator app to verify setup

Request to initiate setup:

http
GET /mfa/setup

Response:

json
{
"message": "MFA setup initiated",
"secret": "JBSWY3DPEHPK3PXP",
"qrCode": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..."
}

Request to verify and enable:

http
POST /mfa/verify
Content-Type: application/json

{
"token": "123456"
}

Response:

json
{
"message": "MFA enabled successfully"
}

Login with MFA

  1. User enters username and password
  2. System recognizes MFA is enabled and prompts for code
  3. User enters the code from their authenticator app
  4. If valid, user is fully authenticated

First factor authentication:

http
POST /login
Content-Type: application/json

{
"username": "johnsmith",
"password": "securepassword123"
}

Response:

json
{
"message": "Please enter your MFA code",
"requireMFA": true
}

Second factor authentication:

http
POST /mfa/validate
Content-Type: application/json

{
"token": "123456"
}

Response:

json
{
"message": "Authentication successful"
}

Best Practices for Implementing MFA

  1. Always store secrets securely - Never store MFA secrets in plaintext in your database. Use encryption.

  2. Provide recovery options - Generate and securely store recovery codes that users can use if they lose their device.

  3. Consider rate limiting - Limit MFA verification attempts to prevent brute force attacks.

  4. Use a time window - Allow for small time drift between server and client devices by accepting tokens from adjacent time windows.

  5. Implement proper session management - Make sure to handle partially authenticated sessions correctly.

  6. Consider backup methods - SMS or email backup can be offered, although they are less secure than authenticator apps.

Security Considerations

While implementing MFA, keep these security factors in mind:

  • Database Security: Always encrypt MFA secrets in your database
  • Session Security: Use HTTPS and secure cookies
  • Recovery Process: Design a secure account recovery process
  • Education: Educate users about the importance of MFA

Advanced MFA Options

Beyond TOTP, you can consider implementing:

  • Push notifications: Send approval requests to a trusted device
  • Security keys: Support hardware security keys like YubiKey
  • Biometrics: Integrate with fingerprint or facial recognition systems (generally client-side)
  • Adaptive MFA: Only require additional factors when login appears risky

Summary

Multi-factor authentication significantly enhances the security of your Express applications by requiring multiple verification methods before granting access. In this tutorial, we implemented a complete TOTP-based two-factor authentication system that:

  1. Allows users to register with basic authentication
  2. Provides a mechanism to enroll in MFA by scanning a QR code
  3. Enforces MFA during login for enrolled users
  4. Protects routes by ensuring full authentication
  5. Allows users to disable MFA if needed

By following these patterns, you can create a secure authentication system that protects your users from various threats like credential stuffing, phishing, and password breaches.

Additional Resources

Exercises

  1. Enhance the MFA implementation by adding recovery codes when a user enables MFA
  2. Implement rate limiting for MFA verification attempts
  3. Add SMS verification as an alternative second factor
  4. Create a user interface to display the QR code and verify the token
  5. Modify the code to store user data securely in MongoDB instead of an in-memory array


If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)