WorkOS Multi-Factor Authentication
Step 1: Fetch Documentation (BLOCKING)
STOP. Do not proceed until complete.
WebFetch these URLs — they are the source of truth:
If this skill conflicts with fetched docs, follow the docs.
Step 2: Pre-Flight Validation
Account Setup
Check WorkOS Dashboard at https://dashboard.workos.com :
-
API Keys section exists
-
You have copied WORKOS_API_KEY (starts with sk_ )
-
You have copied WORKOS_CLIENT_ID (starts with client_ )
Environment Variables
Check .env or .env.local for:
WORKOS_API_KEY=sk_... WORKOS_CLIENT_ID=client_...
Verify before continuing:
Check both keys are set
env | grep WORKOS_API_KEY env | grep WORKOS_CLIENT_ID
SDK Installation
Detect package manager and verify WorkOS SDK is installed:
Check SDK exists in node_modules
ls node_modules/@workos-inc/node 2>/dev/null ||
ls node_modules/@workos-inc/python 2>/dev/null ||
echo "SDK not found - install before proceeding"
Do not proceed until SDK package exists.
Step 3: Factor Type Selection (Decision Tree)
MFA Factor Type? | +-- TOTP (Authenticator App) | | | +-- User has Google Authenticator, Authy, etc. | +-- Use enrollFactor() with type: 'totp' | +-- Display QR code + secret from response | +-- SMS (Text Message) | +-- User provides phone number +-- Validate phone number format BEFORE API call +-- Use enrollFactor() with type: 'sms' +-- Challenge expires after 10 minutes
Critical: Phone numbers must be E.164 format (+1234567890 ). Invalid formats return API error.
Step 4: Enroll Authentication Factor
TOTP Enrollment
SDK Method: workos.mfa.enrollFactor()
Parameters:
-
type : 'totp'
-
totp_issuer : Your app name (shows in authenticator app)
-
totp_user : User identifier (email or username)
Response contains:
-
id — Factor ID (MUST persist in your user table)
-
qr_code — Base64 data URI for QR code display
-
secret — Manual entry code (for users who can't scan QR)
Display pattern:
<!-- QR Code: src is the base64 data URI from response --> <img src="{qr_code}" alt="Scan with authenticator app" />
<!-- Manual entry fallback --> <p>Or enter code manually: {secret}</p>
SMS Enrollment
SDK Method: workos.mfa.enrollFactor()
Parameters:
-
type : 'sms'
-
phone_number : E.164 format string
Phone validation (REQUIRED before API call):
// Example validation regex const e164Regex = /^+[1-9]\d{1,14}$/; if (!e164Regex.test(phoneNumber)) { throw new Error('Phone must be E.164 format: +1234567890'); }
Response contains:
- id — Factor ID (MUST persist in your user table)
Persist Factor ID (CRITICAL)
The id from enrollment response MUST be stored in your user database:
-- Example schema ALTER TABLE users ADD COLUMN mfa_factor_id VARCHAR(255); UPDATE users SET mfa_factor_id = ? WHERE user_id = ?;
Do NOT proceed to verification without persisting factor ID.
Step 5: Create Challenge
When: User attempts sign-in after enrolling MFA.
SDK Method: workos.mfa.challengeFactor()
Parameters:
- authentication_factor_id : The factor ID from Step 4
Response contains:
-
id — Challenge ID (needed for verification)
-
expires_at — ISO timestamp (SMS only, 10 minutes from creation)
SMS-specific behavior:
-
Challenge sends OTP to enrolled phone number
-
Challenge expires after 10 minutes
-
Expired challenges cannot be verified — create new challenge
Step 6: Verify Challenge
SDK Method: workos.mfa.verifyChallenge()
Parameters:
-
authentication_challenge_id : Challenge ID from Step 5
-
code : OTP entered by user (6 digits for TOTP/SMS)
Response:
- valid : true (success) or false (incorrect code)
Sign-In Flow Integration
Standard sign-in flow:
- User enters username + password
- Validate credentials
- --> Check if user has mfa_factor_id | +-- YES --> Redirect to MFA verification page | | | +-- Create challenge (Step 5) | +-- User enters code | +-- Verify challenge (Step 6) | +-- If valid: create session | +-- If invalid: show error, allow retry | +-- NO --> Create session directly
Do NOT grant session access until challenge verification returns valid: true .
Step 7: Error Handling Implementation
Add these specific error handlers to your verification code:
Challenge Already Verified
Error condition: Attempting to verify same challenge twice.
Response: API returns error indicating challenge was already used.
Fix: Create a NEW challenge via challengeFactor() , do not reuse challenge IDs.
Code pattern:
try { const result = await workos.mfa.verifyChallenge({ authentication_challenge_id: challengeId, code: userCode }); } catch (error) { if (error.message.includes('already verified')) { // Create new challenge const newChallenge = await workos.mfa.challengeFactor({ authentication_factor_id: factorId }); // Prompt user to enter new code } }
Challenge Expired (SMS Only)
Error condition: Challenge created >10 minutes ago.
Response: API returns expired error.
Fix: Create a NEW challenge — expired challenges are permanently invalid.
Code pattern:
catch (error) { if (error.message.includes('expired')) { const newChallenge = await workos.mfa.challengeFactor({ authentication_factor_id: factorId }); showMessage('Code expired. New code sent.'); } }
Invalid Phone Number
Error condition: Phone not in E.164 format during enrollment.
Response: API returns validation error.
Fix: Validate phone format BEFORE calling enrollFactor() (see Step 4).
Invalid Code Format
Error condition: User enters non-numeric or wrong-length code.
Client-side validation (recommended):
if (!/^\d{6}$/.test(code)) { showError('Code must be 6 digits'); return; }
Verification Checklist (ALL MUST PASS)
Run these commands to confirm integration:
1. Check environment variables are set
env | grep -E "WORKOS_(API_KEY|CLIENT_ID)" | wc -l
Must output: 2
2. Check SDK is installed
ls node_modules/@workos-inc/node/package.json 2>/dev/null || echo "FAIL: SDK missing"
3. Check user schema has factor ID column
(Adjust for your database - example for PostgreSQL)
psql -c "\d users" | grep mfa_factor_id || echo "FAIL: Schema missing MFA column"
4. Check enrollment endpoint exists
grep -r "enrollFactor" . --include=".js" --include=".ts" || echo "FAIL: No enrollment code"
5. Check verification endpoint exists
grep -r "verifyChallenge" . --include=".js" --include=".ts" || echo "FAIL: No verification code"
6. Check error handling exists
grep -r "already verified|expired" . --include=".js" --include=".ts" || echo "FAIL: Missing error handlers"
Do not mark complete until all checks pass.
Testing Procedure
TOTP Flow Test
-
Enroll factor with type totp
-
Scan QR code with Google Authenticator
-
Create challenge
-
Enter 6-digit code from app
-
Verify challenge returns valid: true
-
Attempt to verify same challenge again — should error
SMS Flow Test
-
Enroll factor with valid E.164 phone number
-
Create challenge — should receive SMS within 30 seconds
-
Enter 6-digit code from SMS
-
Verify challenge returns valid: true
-
Wait 11 minutes, create new challenge with same factor
-
Attempt to verify old challenge — should return expired error
Invalid Input Tests
-
Attempt enrollment with phone 1234567890 (no + ) — should error
-
Attempt enrollment with phone +1 (555) 123-4567 (formatted) — should error
-
Attempt verification with code 12345 (5 digits) — should fail validation
-
Attempt verification with code abc123 (non-numeric) — should fail validation
Common Integration Patterns
Express.js Middleware Pattern
// middleware/mfa.js async function requireMFA(req, res, next) { const user = req.user; // From your auth middleware
if (!user.mfa_factor_id) { return next(); // No MFA enrolled, skip }
if (req.session.mfaVerified) { return next(); // Already verified this session }
return res.redirect('/mfa/verify'); }
Next.js API Route Pattern
// pages/api/auth/verify-mfa.js export default async function handler(req, res) { const { challengeId, code } = req.body;
try { const result = await workos.mfa.verifyChallenge({ authentication_challenge_id: challengeId, code: code });
if (result.valid) {
req.session.mfaVerified = true;
return res.json({ success: true });
}
return res.status(401).json({ error: 'Invalid code' });
} catch (error) { // Handle specific errors per Step 7 } }
Security Considerations
-
Rate limiting: Implement rate limits on verification endpoint (max 5 attempts per challenge recommended)
-
Session handling: Mark MFA as verified in session, do not re-verify on every request
-
Factor ID exposure: Factor IDs are sensitive — do not expose in client-side code or URLs
-
Challenge reuse: Never allow same challenge ID to be verified twice
-
Code storage: Never log or store OTP codes — they should only exist in transit
WorkOS SSO Note
CRITICAL: Do NOT use MFA API with WorkOS SSO-authenticated users.
SSO providers (Okta, Azure AD, Google Workspace, etc.) have their own MFA:
-
Users configure MFA in their IdP (Identity Provider)
-
MFA is enforced at IdP during SSO flow
-
WorkOS SSO inherits MFA state from IdP
Use MFA API only for:
-
Username/password authenticated users
-
Magic link authenticated users
-
Any non-SSO authentication method
Check authentication method before enabling MFA:
if (user.authMethod === 'sso') { // Do not offer MFA enrollment // MFA is handled by their SSO provider }
Related Skills
-
workos-authkit-base: For primary authentication before MFA
-
workos-magic-link: Alternative to password auth (can be combined with MFA)