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:
- Knowledge - Something you know (password, PIN, security questions)
- Possession - Something you have (mobile device, security key, authentication app)
- 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:
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:
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:
// 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:
// 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:
// 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:
// 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
- User registers with username and password
- Initially, MFA is not enabled
Request:
POST /register
Content-Type: application/json
{
"username": "johnsmith",
"password": "securepassword123"
}
Response:
{
"message": "User registered successfully"
}
Setting Up MFA
- User logs in with username and password
- User visits MFA setup page
- User is shown a QR code to scan with authenticator app
- User enters the code from their authenticator app to verify setup
Request to initiate setup:
GET /mfa/setup
Response:
{
"message": "MFA setup initiated",
"secret": "JBSWY3DPEHPK3PXP",
"qrCode": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..."
}
Request to verify and enable:
POST /mfa/verify
Content-Type: application/json
{
"token": "123456"
}
Response:
{
"message": "MFA enabled successfully"
}
Login with MFA
- User enters username and password
- System recognizes MFA is enabled and prompts for code
- User enters the code from their authenticator app
- If valid, user is fully authenticated
First factor authentication:
POST /login
Content-Type: application/json
{
"username": "johnsmith",
"password": "securepassword123"
}
Response:
{
"message": "Please enter your MFA code",
"requireMFA": true
}
Second factor authentication:
POST /mfa/validate
Content-Type: application/json
{
"token": "123456"
}
Response:
{
"message": "Authentication successful"
}
Best Practices for Implementing MFA
-
Always store secrets securely - Never store MFA secrets in plaintext in your database. Use encryption.
-
Provide recovery options - Generate and securely store recovery codes that users can use if they lose their device.
-
Consider rate limiting - Limit MFA verification attempts to prevent brute force attacks.
-
Use a time window - Allow for small time drift between server and client devices by accepting tokens from adjacent time windows.
-
Implement proper session management - Make sure to handle partially authenticated sessions correctly.
-
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:
- Allows users to register with basic authentication
- Provides a mechanism to enroll in MFA by scanning a QR code
- Enforces MFA during login for enrolled users
- Protects routes by ensuring full authentication
- 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
- Speakeasy Documentation
- QRCode Generation Library
- OWASP Authentication Cheat Sheet
- NIST Digital Identity Guidelines
Exercises
- Enhance the MFA implementation by adding recovery codes when a user enables MFA
- Implement rate limiting for MFA verification attempts
- Add SMS verification as an alternative second factor
- Create a user interface to display the QR code and verify the token
- 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! :)