In this article
July 16, 2025
July 16, 2025

How to add MFA to your homegrown auth using WorkOS

Learn how to add Multi-Factor Authentication (MFA) to your homegrown authentication system using WorkOS, with detailed code examples for TOTP and SMS-based flows.

Multi-Factor Authentication (MFA) adds a vital security layer to your authentication system. In this guide, you'll learn how to integrate WorkOS’s MFA API into a homegrown authentication system using either TOTP (Time-based One-Time Password) or SMS-based verification.

What you will learn:

  • How to enroll authentication factors using WorkOS
  • How to create authentication challenges (e.g., send an SMS code)
  • How to verify MFA challenges
  • How to store and manage factor and challenge IDs
  • Best practices for integrating with your existing auth flow

Let’s dive right in!

Prerequisites

  • A WorkOS account
  • Your WORKOS_API_KEY and WORKOS_CLIENT_ID
  • A server-side application in Node.js (or similar; we’ll use Node.js in examples). If you use a different language, refer to our MFA docs for code samples in Python, Go, Ruby, and more.

Step 1: Install the WorkOS SDK

For Node.js, install the WorkOS SDK using either npm or Yarn:

	

	

Step 2: Configure the WorkOS SDK

To make calls to WorkOS, you must authenticate using the WorkOS API key and client ID. Copy these values from the WorkOS dashboard.

Store the values as managed secrets and pass them to the SDK either as environment variables or directly in your app’s configuration.

!!For more info on how to handle secrets safely, see Best practices for secrets management.!!

Environment variables example:

	
WORKOS_API_KEY='sk_example_123456789'
WORKOS_CLIENT_ID='client_123456789'
	

Then, initialize the SDK in your app:

	
// mfaService.js
const WorkOS = require('@workos-inc/node').default;
const workos = new WorkOS(process.env.WORKOS_API_KEY);
	

Step 3: Enroll an authentication factor

You can choose either TOTP (for apps like Google Authenticator) or SMS.

We recommend using TOTP since SMS is not a secure MFA method.

Option A: TOTP

	
const factor = await workos.mfa.enrollFactor({
  type: 'totp',
  issuer: 'Foo Corp',
  user: 'alan.turing@example.com',
});
	
  • qr_code is a base64 data URI for rendering a QR image.
  • secret can be typed into authenticator apps manually.

Option B: SMS Enrollment

	
const factor = await workos.mfa.enrollFactor({
  type: 'sms',
  phoneNumber: '+15005550006',
});
	

Save the factorId in your user database for future verifications.

Step 4: Create an MFA challenge

Now that a factor is enrolled, challenge it when the user signs in.

	
const challenge = await workos.mfa.challengeFactor({
  authenticationFactorId: 'auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ',
});
	

If you are using SMS, you can set a custom SMS message.

	
const enrollResponse = await workos.mfa.challengeFactor({
  authenticationFactorId: 'auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ',
  smsTemplate: 'Your FooCorp is {{code}}.',
});
	

Step 5: Verify the MFA challenge

After the user submits their code:

	
const { challenge, valid } = await workos.mfa.verifyChallenge({
  authenticationChallengeId: 'auth_challenge_01FVYZWQTZQ5VB6BC5MPG2EYC5',
  code: '123456',
});
	

If the challenge is successfully verified valid will return true. Otherwise it will return false and another verification attempt must be made.

	
{
  "challenge": {
    "object": "authentication_challenge",
    "id": "auth_challenge_01FVYZWQTZQ5VB6BC5MPG2EYC5",
    "created_at": "2022-02-15T15:26:53.274Z",
    "updated_at": "2022-02-15T15:26:53.274Z",
    "expires_at": "2022-02-15T15:36:53.279Z",
    "authentication_factor_id": "auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ"
  },
  "valid": true
}
	

Error handling

Already verified error

If a challenge was already successfully verified, it cannot be used a second time. If further verification is needed in your application, create a new challenge.

	
{
  "code": "authentication_challenge_previously_verified",
  "message": "The authentication challenge 'auth_challenge_01FVYZWQTZQ5VB6BC5MPG2EYC5' has already been verified."
}
	

Expired error

For SMS authentication factors, challenges are only available for verification for 10 minutes. After that they are expired and cannot be verified.

	
{
  "code": "authentication_challenge_expired",
  "message": "The authentication challenge 'auth_challenge_01FVYZWQTZQ5VB6BC5MPG2EYC5' has expired."
}
	

Integrating with your auth flow

Here’s how this might look in a simple login flow:

  1. User logs in with email/password
  2. Server checks if user has a stored factorId
  3. If yes:
    1. Challenge the factor
    2. Prompt user for code
    3. Verify the challenge
  4. If no:
    1. Enroll user in MFA (TOTP or SMS)
    2. Prompt for initial setup
    3. Save the factor ID

Security best practices for MFA

  • Deprioritize SMS as a primary factor: Prefer TOTP (e.g. via Google Authenticator, Authy, 1Password, etc.) as the default MFA option. Offer SMS as a fallback only if necessary. Problems with SMS MFA:
    • Vulnerable to SIM-swapping: Attackers can hijack SMS-based MFA by socially engineering mobile carriers.
    • Susceptible to phishing and malware: SMS codes can be intercepted via insecure networks or malicious apps.
    • No end-to-end encryption: SMS is not a secure transport channel.
  • Enforce time-bound challenges:
    • Challenge tokens (e.g., SMS or TOTP) should have short lifetimes — WorkOS defaults to 10 minutes for SMS.
    • Consider implementing rate limiting and cooldown periods for repeated verification attempts.
  • One-time use only:
    • Never allow reuse of a successful MFA challenge.
    • After a successful verification, the challenge ID should be marked as consumed or invalidated. WorkOS enforces this already — lean on that logic.
  • Audit and log MFA events:
    • Log all MFA enrollment, challenge, and verification attempts.
    • Include metadata like IP address, user agent, timestamp, and MFA method used.
    • Monitor for anomalies such as multiple failed challenges or enrollment from new devices.
  • Rotate and re-enroll MFA devices:
    • Provide mechanisms for users to update, re-enroll, or reset their MFA devices securely.
    • If suspicious activity is detected, force re-enrollment or invalidate existing factors.
  • Mitigate MFA fatigue:
    • Limit MFA prompts for low-risk sessions (e.g., within the same device/browser).
    • But always re-challenge on high-risk actions such as:
      • Changing email or password
      • Accessing admin dashboards
      • Viewing sensitive data (PII, payment info)
  • Regularly test and monitor MFA enforcement:
    • Periodically test that MFA is enforced where expected.
    • Run integration tests to verify that challenges, expirations, and verifications behave correctly.
  • Secure secrets:
    • Never expose the TOTP secret or SMS challenge codes outside of controlled flows.
    • Always store factorId and challengeId securely.
    • Store secrets securely (e.g., encrypted at rest, never in logs).
  • Use HTTPS when displaying the qr_code or accepting user input.
  • Limit retry attempts for verification to prevent brute-force.

References

This site uses cookies to improve your experience. Please accept the use of cookies on this site. You can review our cookie policy here and our privacy policy here. If you choose to refuse, functionality of this site will be limited.