Implementing Multi-Factor Authentication with JWT and Time-Based OTP in Node.js
Introduction
Multi-Factor Authentication (MFA) has become essential for protecting user accounts in modern web applications. As cyber threats evolve, relying solely on passwords is no longer sufficient. In this guide, we'll implement a robust MFA system using JSON Web Tokens (JWT) and Time-Based One-Time Passwords (TOTP) in Node.js.
Understanding the MFA Flow
Our MFA implementation will follow this secure flow:
- User provides username and password
- Server validates credentials and issues a temporary JWT
- User must provide TOTP code from their authenticator app
- Server validates TOTP and issues a full access JWT
- Subsequent requests use the full access token
This approach ensures that even if credentials are compromised, attackers cannot access the account without the second factor.
Setting Up Dependencies
First, let's install the required packages:
npm install express jsonwebtoken speakeasy qrcode bcryptjs helmet express-rate-limitHere's our basic server setup with security middleware:
const express = require('express');
const jwt = require('jsonwebtoken');
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
const bcrypt = require('bcryptjs');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const app = express();
// Security middleware
app.use(helmet());
app.use(express.json({ limit: '10mb' }));
// Rate limiting for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per window
message: 'Too many authentication attempts'
});
app.use('/auth', authLimiter);User Model and TOTP Secret Management
For this example, we'll use a simple in-memory user store, but in production, you'd use a proper database:
// In-memory user store (use database in production)
const users = new Map();
// User registration with TOTP setup
app.post('/auth/register', async (req, res) => {
try {
const { username, password, email } = req.body;
if (users.has(username)) {
return res.status(400).json({ error: 'User already exists' });
}
// Generate TOTP secret
const secret = speakeasy.generateSecret({
name: `MyApp (${username})`,
issuer: 'MyApp',
length: 32
});
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Store user
users.set(username, {
username,
email,
password: hashedPassword,
totpSecret: secret.base32,
mfaEnabled: false
});
// Generate QR code for authenticator app
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);
res.json({
message: 'User registered successfully',
qrCode: qrCodeUrl,
manualEntryKey: secret.base32
});
} catch (error) {
res.status(500).json({ error: 'Registration failed' });
}
});Implementing the Login Flow
Our login process involves two steps: initial authentication and TOTP verification:
// Step 1: Initial login
app.post('/auth/login', async (req, res) => {
try {
const { username, password } = req.body;
const user = users.get(username);
if (!user || !await bcrypt.compare(password, user.password)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Issue temporary token for MFA step
const tempToken = jwt.sign(
{
username,
step: 'mfa_pending',
exp: Math.floor(Date.now() / 1000) + 300 // 5 minutes
},
process.env.JWT_SECRET
);
res.json({
message: 'Credentials valid, provide TOTP code',
tempToken,
mfaRequired: true
});
} catch (error) {
res.status(500).json({ error: 'Login failed' });
}
});
// Step 2: TOTP verification
app.post('/auth/verify-mfa', (req, res) => {
try {
const { tempToken, totpCode } = req.body;
// Verify temporary token
const decoded = jwt.verify(tempToken, process.env.JWT_SECRET);
if (decoded.step !== 'mfa_pending') {
return res.status(401).json({ error: 'Invalid token' });
}
const user = users.get(decoded.username);
// Verify TOTP code
const verified = speakeasy.totp.verify({
secret: user.totpSecret,
encoding: 'base32',
token: totpCode,
window: 2 // Allow 2 time steps (60 seconds) of variance
});
if (!verified) {
return res.status(401).json({ error: 'Invalid TOTP code' });
}
// Enable MFA if first successful verification
if (!user.mfaEnabled) {
user.mfaEnabled = true;
}
// Issue full access token
const accessToken = jwt.sign(
{
username: user.username,
step: 'authenticated',
exp: Math.floor(Date.now() / 1000) + 3600 // 1 hour
},
process.env.JWT_SECRET
);
res.json({
message: 'Authentication successful',
accessToken,
user: { username: user.username, email: user.email }
});
} catch (error) {
res.status(401).json({ error: 'MFA verification failed' });
}
});Protecting Routes with MFA Middleware
Create middleware to ensure only fully authenticated users can access protected resources:
// MFA authentication middleware
const requireMFA = (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (decoded.step !== 'authenticated') {
return res.status(401).json({ error: 'Full authentication required' });
}
req.user = decoded;
next();
} catch (error) {
res.status(401).json({ error: 'Invalid or expired token' });
}
};
// Protected route example
app.get('/api/profile', requireMFA, (req, res) => {
const user = users.get(req.user.username);
res.json({
username: user.username,
email: user.email,
mfaEnabled: user.mfaEnabled
});
});Security Best Practices
When implementing MFA, consider these security measures:
- Token Expiry: Use short-lived temporary tokens and implement refresh mechanisms
- Rate Limiting: Implement aggressive rate limiting on authentication endpoints
- Secure Storage: Store TOTP secrets encrypted in your database
- Backup Codes: Provide users with backup recovery codes
- Audit Logging: Log all authentication attempts for security monitoring
Testing Your MFA Implementation
Test the complete flow using popular authenticator apps like Google Authenticator or Authy. Scan the QR code during registration and verify that the TOTP codes work correctly with your implementation.
Conclusion
Implementing MFA with JWT and TOTP significantly enhances your application's security posture. This implementation provides a solid foundation that you can extend with additional features like backup codes, remember device functionality, and administrative MFA enforcement. Remember to always use HTTPS in production and keep your dependencies updated to address security vulnerabilities.
Related Posts
Implementing OAuth 2.0 Authentication with PKCE in Modern Web Applications
Learn how to implement secure OAuth 2.0 authentication with PKCE flow to protect your web applications from common security vulnerabilities.
Building a High-Performance Authentication System with JWT and Redis
Learn to implement secure JWT authentication with Redis for session management, refresh tokens, and blacklisting in Node.js applications.
Building Secure JWT Authentication in Laravel with Best Practices
Learn how to implement bulletproof JWT authentication in Laravel with proper security measures and token management.