The Mfa class provides methods for managing multi-factor authentication (MFA) factors and challenges.
Overview
WorkOS MFA supports multiple authentication factor types:
- TOTP (Time-based One-Time Password): Authenticator apps like Google Authenticator, Authy
- SMS: One-time codes sent via text message
- Generic OTP: Custom one-time password implementations
Factor Management
enrollFactor
Enrolls a new authentication factor for MFA.
TOTP Enrollment
Name of your application (displayed in authenticator apps)
User identifier (typically email or username)
Unique identifier for the factor
Base64-encoded QR code image for scanning with authenticator apps
Secret key for manual entry
otpauth:// URI for deep linking to authenticator apps
const factor = await workos.mfa.enrollFactor({
type: 'totp',
issuer: 'MyApp',
user: '[email protected]'
});
if (factor.totp) {
// Display QR code to user
console.log('QR Code:', factor.totp.qrCode);
// Or provide secret for manual entry
console.log('Secret:', factor.totp.secret);
}
SMS Enrollment
Phone number in E.164 format (e.g., +14155551234)
Unique identifier for the factor
const factor = await workos.mfa.enrollFactor({
type: 'sms',
phoneNumber: '+14155551234'
});
console.log('SMS factor enrolled:', factor.id);
getFactor
Retrieves details of a specific authentication factor.
The authentication factor ID
type
'totp' | 'sms' | 'generic_otp'
Factor type
const factor = await workos.mfa.getFactor('auth_factor_01HZXK8F9P3QZJQ2Z1Z2Z3Z4Z5');
console.log('Factor type:', factor.type);
deleteFactor
Deletes an authentication factor.
The authentication factor ID to delete
await workos.mfa.deleteFactor('auth_factor_01HZXK8F9P3QZJQ2Z1Z2Z3Z4Z5');
console.log('Factor deleted');
Challenge & Verification
challengeFactor
Creates a challenge for an authentication factor. For SMS factors, this sends the OTP code via text message.
The ID of the authentication factor to challenge
Custom SMS template (only for SMS factors). Use {{code}} as placeholder for the OTP code.
Unique identifier for the challenge
ISO 8601 timestamp when challenge expires
The OTP code (only included in development/test environments)
// For TOTP factors
const challenge = await workos.mfa.challengeFactor({
authenticationFactorId: 'auth_factor_01HZXK8F9P3QZJQ2Z1Z2Z3Z4Z5'
});
// For SMS factors with custom template
const smsChallenge = await workos.mfa.challengeFactor({
authenticationFactorId: 'auth_factor_01HZXK8F9P3QZJQ2Z1Z2Z3Z4Z5',
smsTemplate: 'Your MyApp verification code is: {{code}}'
});
console.log('Challenge ID:', challenge.id);
console.log('Expires at:', challenge.expiresAt);
verifyChallenge
Verifies a challenge with the user-provided OTP code.
authenticationChallengeId
The ID of the challenge to verify
The OTP code provided by the user
The verified challenge object
Whether the code was valid
const result = await workos.mfa.verifyChallenge({
authenticationChallengeId: 'auth_challenge_01HZXK8F9P3QZJQ2Z1Z2Z3Z4Z5',
code: '123456'
});
if (result.valid) {
console.log('MFA verification successful!');
// Proceed with authentication
} else {
console.log('Invalid code');
// Show error to user
}
Complete MFA Flows
TOTP Enrollment Flow
import { WorkOS } from '@workos-inc/node';
const workos = new WorkOS(process.env.WORKOS_API_KEY);
// Step 1: Enroll TOTP factor
app.post('/mfa/enroll/totp', async (req, res) => {
const { userId, email } = req.user;
try {
const factor = await workos.mfa.enrollFactor({
type: 'totp',
issuer: 'MyApp',
user: email
});
// Store factor ID associated with user
await db.users.update(userId, {
mfaFactorId: factor.id
});
res.json({
factorId: factor.id,
qrCode: factor.totp?.qrCode,
secret: factor.totp?.secret
});
} catch (error) {
console.error('TOTP enrollment error:', error);
res.status(500).json({ error: 'Failed to enroll TOTP' });
}
});
// Step 2: Verify TOTP setup
app.post('/mfa/verify-setup', async (req, res) => {
const { factorId, code } = req.body;
// Create a challenge
const challenge = await workos.mfa.challengeFactor({
authenticationFactorId: factorId
});
// Verify the code
const result = await workos.mfa.verifyChallenge({
authenticationChallengeId: challenge.id,
code
});
if (result.valid) {
// Mark MFA as enabled for user
await db.users.update(req.user.userId, {
mfaEnabled: true
});
res.json({ success: true });
} else {
res.status(400).json({ error: 'Invalid code' });
}
});
SMS Enrollment Flow
// Step 1: Enroll SMS factor
app.post('/mfa/enroll/sms', async (req, res) => {
const { phoneNumber } = req.body;
const { userId } = req.user;
// Validate phone number format (E.164)
if (!phoneNumber.match(/^\+[1-9]\d{1,14}$/)) {
return res.status(400).json({
error: 'Invalid phone number format. Use E.164 format (e.g., +14155551234)'
});
}
try {
const factor = await workos.mfa.enrollFactor({
type: 'sms',
phoneNumber
});
// Store factor ID
await db.users.update(userId, {
mfaFactorId: factor.id
});
// Send verification code immediately
const challenge = await workos.mfa.challengeFactor({
authenticationFactorId: factor.id,
smsTemplate: 'Your MyApp verification code is: {{code}}'
});
res.json({
factorId: factor.id,
challengeId: challenge.id,
message: 'Verification code sent'
});
} catch (error) {
console.error('SMS enrollment error:', error);
res.status(500).json({ error: 'Failed to enroll SMS' });
}
});
// Step 2: Verify SMS code
app.post('/mfa/verify-sms', async (req, res) => {
const { challengeId, code } = req.body;
const result = await workos.mfa.verifyChallenge({
authenticationChallengeId: challengeId,
code
});
if (result.valid) {
await db.users.update(req.user.userId, {
mfaEnabled: true,
phoneVerified: true
});
res.json({ success: true });
} else {
res.status(400).json({ error: 'Invalid code' });
}
});
Login with MFA
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
// Step 1: Verify password
const user = await authenticateUser(email, password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Step 2: Check if MFA is enabled
if (user.mfaEnabled && user.mfaFactorId) {
// Create MFA challenge
const challenge = await workos.mfa.challengeFactor({
authenticationFactorId: user.mfaFactorId,
// For SMS, optionally customize the message
...(user.mfaType === 'sms' && {
smsTemplate: 'Your MyApp login code is: {{code}}'
})
});
// Return challenge ID to client
return res.json({
requiresMfa: true,
challengeId: challenge.id,
userId: user.id
});
}
// No MFA required, create session
const session = await createSession(user);
res.json({ session });
});
// Step 3: Verify MFA and complete login
app.post('/auth/verify-mfa', async (req, res) => {
const { challengeId, code, userId } = req.body;
try {
const result = await workos.mfa.verifyChallenge({
authenticationChallengeId: challengeId,
code
});
if (!result.valid) {
return res.status(401).json({ error: 'Invalid MFA code' });
}
// MFA verified, create session
const user = await db.users.findById(userId);
const session = await createSession(user);
res.json({ session });
} catch (error) {
console.error('MFA verification error:', error);
res.status(500).json({ error: 'MFA verification failed' });
}
});
Managing Multiple Factors
// List all factors for a user (using UserManagement API)
app.get('/mfa/factors', async (req, res) => {
const { userId } = req.user;
const factors = await workos.userManagement.listAuthFactors({
userId
});
const factorList = [];
for await (const factor of factors) {
factorList.push({
id: factor.id,
type: factor.type,
createdAt: factor.createdAt,
// Include sanitized info (don't expose secrets)
...(factor.sms && { phoneNumber: factor.sms.phoneNumber })
});
}
res.json({ factors: factorList });
});
// Remove a factor
app.delete('/mfa/factors/:factorId', async (req, res) => {
const { factorId } = req.params;
const { userId } = req.user;
// Verify factor belongs to user
const factor = await workos.mfa.getFactor(factorId);
// Additional verification logic here
await workos.mfa.deleteFactor(factorId);
// Check if this was the last factor
const remainingFactors = await workos.userManagement.listAuthFactors({ userId });
const hasFactors = remainingFactors.data.length > 0;
if (!hasFactors) {
await db.users.update(userId, { mfaEnabled: false });
}
res.json({ success: true });
});
Error Handling
try {
const challenge = await workos.mfa.challengeFactor({
authenticationFactorId: factorId
});
const result = await workos.mfa.verifyChallenge({
authenticationChallengeId: challenge.id,
code: userCode
});
} catch (error) {
if (error.code === 'invalid_credentials') {
console.error('Invalid MFA code');
} else if (error.code === 'challenge_expired') {
console.error('Challenge has expired, request a new code');
} else if (error.code === 'factor_not_found') {
console.error('Authentication factor not found');
} else if (error.code === 'rate_limit_exceeded') {
console.error('Too many attempts, please try again later');
} else {
console.error('MFA error:', error.message);
}
}
Best Practices
-
Secure factor storage: Store factor IDs securely and associate them with the correct user.
-
Implement rate limiting: Prevent brute force attacks on MFA verification.
-
Handle expiration: Challenges expire after a set time. Allow users to request new codes.
-
Backup codes: Implement backup/recovery codes in case users lose access to their MFA device.
-
Clear UX: Provide clear instructions for TOTP setup (QR code + manual entry option).
-
Phone number validation: Validate phone numbers are in E.164 format before SMS enrollment.
-
Multi-factor support: Allow users to enroll multiple factors for redundancy.
// Example rate limiting for MFA verification
const verificationAttempts = new Map();
app.post('/auth/verify-mfa', async (req, res) => {
const { challengeId, code } = req.body;
const key = `${req.ip}:${challengeId}`;
// Check attempts
const attempts = verificationAttempts.get(key) || 0;
if (attempts >= 5) {
return res.status(429).json({
error: 'Too many attempts. Please request a new code.'
});
}
try {
const result = await workos.mfa.verifyChallenge({
authenticationChallengeId: challengeId,
code
});
if (result.valid) {
// Clear attempts on success
verificationAttempts.delete(key);
// Create session...
} else {
// Increment attempts
verificationAttempts.set(key, attempts + 1);
res.status(401).json({ error: 'Invalid code' });
}
} catch (error) {
verificationAttempts.set(key, attempts + 1);
throw error;
}
});