MFA Management

Complete guide to implementing multi-factor authentication with TOTP, backup codes, and recovery flows

Updated Dec 16, 2025
Edit on GitHub

This guide covers multi-factor authentication (MFA) implementation using Time-based One-Time Passwords (TOTP). You’ll learn how to enable MFA for users, verify codes during login, manage backup codes, and handle recovery scenarios.

Prerequisites

Before implementing MFA, ensure you have:

  • Installed the SDK: npm install @drmhse/sso-sdk
  • An authenticated user session
  • An authenticator app for testing (Google Authenticator, Authy, 1Password, etc.)

Overview

AuthOS provides TOTP-based MFA (compatible with RFC 6238) that works with all standard authenticator apps. MFA adds an extra layer of security by requiring both:

  1. Something the user knows (password)
  2. Something the user has (authenticator app on their device)

Key Features:

  • TOTP (Time-based One-Time Password) support
  • QR code generation for easy setup
  • 10 backup codes for account recovery
  • Backup code regeneration
  • Compatible with all standard authenticator apps

Checking MFA Status

Before enabling or managing MFA, check if the user already has it enabled.

Basic Status Check

import { SsoClient } from '@drmhse/sso-sdk';

const sso = new SsoClient({
  baseURL: 'https://sso.example.com',
  token: accessToken
});

async function checkMfaStatus() {
  try {
    const status = await sso.user.mfa.getStatus();

    console.log('MFA enabled:', status.enabled);

    if (status.enabled) {
      console.log('MFA is currently active');
    } else {
      console.log('MFA is not enabled');
    }

    return status.enabled;
  } catch (error) {
    console.error('Failed to check MFA status:', error);
    return false;
  }
}

React Component for MFA Status

import { useState, useEffect } from 'react';
import { SsoClient } from '@drmhse/sso-sdk';

interface MfaStatusProps {
  sso: SsoClient;
}

export function MfaStatus({ sso }: MfaStatusProps) {
  const [enabled, setEnabled] = useState<boolean | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    checkStatus();
  }, []);

  const checkStatus = async () => {
    try {
      const status = await sso.user.mfa.getStatus();
      setEnabled(status.enabled);
    } catch (error) {
      console.error('Failed to check MFA status:', error);
    } finally {
      setLoading(false);
    }
  };

  if (loading) {
    return <div>Loading MFA status...</div>;
  }

  return (
    <div className="mfa-status">
      <h3>Two-Factor Authentication</h3>
      {enabled ? (
        <div className="status-enabled">
          <span className="badge success">Enabled</span>
          <p>Your account is protected with 2FA</p>
        </div>
      ) : (
        <div className="status-disabled">
          <span className="badge warning">Disabled</span>
          <p>Add an extra layer of security to your account</p>
        </div>
      )}
    </div>
  );
}

Setting Up TOTP MFA

The MFA setup process involves three steps:

  1. Generate a TOTP secret and QR code
  2. User scans QR code with authenticator app
  3. User verifies the setup by entering a code

Step 1: Generate QR Code

async function startMfaSetup() {
  try {
    const setup = await sso.user.mfa.setup();

    console.log('TOTP Secret:', setup.secret);
    console.log('QR Code:', setup.qr_code_svg);

    return {
      secret: setup.secret,
      qrCode: setup.qr_code_svg,
      otpauthUrl: setup.otpauth_url
    };
  } catch (error) {
    console.error('Failed to setup MFA:', error);
    throw error;
  }
}

Step 2: Display QR Code to User

import { useState } from 'react';
import { SsoClient, SsoApiError } from '@drmhse/sso-sdk';

interface MfaSetupProps {
  sso: SsoClient;
  onComplete: () => void;
}

export function MfaSetupFlow({ sso, onComplete }: MfaSetupProps) {
  const [step, setStep] = useState<'init' | 'scan' | 'verify'>('init');
  const [qrCode, setQrCode] = useState('');
  const [secret, setSecret] = useState('');
  const [code, setCode] = useState('');
  const [backupCodes, setBackupCodes] = useState<string[]>([]);
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);

  const startSetup = async () => {
    setLoading(true);
    setError('');

    try {
      const setup = await sso.user.mfa.setup();
      setQrCode(setup.qr_code_svg);
      setSecret(setup.secret);
      setStep('scan');
    } catch (err) {
      if (err instanceof SsoApiError) {
        setError(err.message);
      } else {
        setError('Failed to setup MFA. Please try again.');
      }
    } finally {
      setLoading(false);
    }
  };

  const verifySetup = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    setError('');

    try {
      const result = await sso.user.mfa.verify(code);
      setBackupCodes(result.backup_codes);
      setStep('verify');
    } catch (err) {
      if (err instanceof SsoApiError) {
        setError('Invalid code. Please try again.');
      } else {
        setError('Verification failed. Please try again.');
      }
      setLoading(false);
    }
  };

  // Step 1: Initial state
  if (step === 'init') {
    return (
      <div className="mfa-setup-init">
        <h2>Enable Two-Factor Authentication</h2>
        <p>
          Protect your account with an extra layer of security. You'll need an
          authenticator app like Google Authenticator, Authy, or 1Password.
        </p>

        <div className="benefits">
          <h3>Benefits of 2FA:</h3>
          <ul>
            <li>Protect against password theft</li>
            <li>Secure access even if your password is compromised</li>
            <li>Meet compliance requirements</li>
          </ul>
        </div>

        <button onClick={startSetup} disabled={loading}>
          {loading ? 'Setting up...' : 'Get Started'}
        </button>
      </div>
    );
  }

  // Step 2: Scan QR code
  if (step === 'scan') {
    return (
      <div className="mfa-setup-scan">
        <h2>Scan QR Code</h2>

        <div className="instructions">
          <p>
            <strong>Step 1:</strong> Open your authenticator app
          </p>
          <p>
            <strong>Step 2:</strong> Scan this QR code
          </p>
        </div>

        <div
          className="qr-code"
          dangerouslySetInnerHTML={{ __html: qrCode }}
        />

        <div className="manual-entry">
          <p>Can't scan? Enter this code manually:</p>
          <code>{secret}</code>
          <button onClick={() => navigator.clipboard.writeText(secret)}>
            Copy Code
          </button>
        </div>

        <form onSubmit={verifySetup}>
          <div className="form-group">
            <label htmlFor="code">
              <strong>Step 3:</strong> Enter the 6-digit code from your app
            </label>
            <input
              id="code"
              type="text"
              value={code}
              onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
              maxLength={6}
              placeholder="000000"
              required
              autoFocus
            />
          </div>

          {error && <div className="error">{error}</div>}

          <button type="submit" disabled={loading || code.length !== 6}>
            {loading ? 'Verifying...' : 'Verify and Enable'}
          </button>
        </form>
      </div>
    );
  }

  // Step 3: Show backup codes
  return (
    <div className="mfa-setup-complete">
      <h2>Two-Factor Authentication Enabled!</h2>

      <div className="backup-codes-intro">
        <p className="warning">
          <strong>Important:</strong> Save these backup codes in a secure place.
          You can use them to access your account if you lose your authenticator device.
        </p>
      </div>

      <div className="backup-codes">
        <h3>Your Backup Codes</h3>
        <div className="codes-grid">
          {backupCodes.map((code, index) => (
            <code key={index}>{code}</code>
          ))}
        </div>
        <button
          onClick={() => {
            const text = backupCodes.join('\n');
            navigator.clipboard.writeText(text);
          }}
        >
          Copy All Codes
        </button>
        <button
          onClick={() => {
            const blob = new Blob([backupCodes.join('\n')], { type: 'text/plain' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = 'backup-codes.txt';
            a.click();
          }}
        >
          Download Codes
        </button>
      </div>

      <button onClick={onComplete} className="primary">
        Done
      </button>
    </div>
  );
}

Verifying TOTP Code to Enable MFA

After the user scans the QR code, they must verify a code to complete setup.

Verification

async function verifyAndEnableMfa(code: string) {
  try {
    const result = await sso.user.mfa.verify(code);

    console.log('MFA enabled successfully!');
    console.log('Backup codes:', result.backup_codes);

    // IMPORTANT: Display backup codes to user
    // User MUST save these before continuing
    return result.backup_codes;
  } catch (error) {
    if (error instanceof SsoApiError) {
      console.error('Invalid code:', error.message);
    }
    throw error;
  }
}

Important: The backup codes are only shown once during setup. Users must save them securely.

Login with MFA

When a user with MFA enabled attempts to log in, they receive a pre-authentication token and must verify their MFA code to complete authentication.

MFA Login Flow

async function loginWithMfa(email: string, password: string) {
  // Step 1: Login with password
  const tokens = await sso.auth.login({
    email,
    password
  });

  // Step 2: Check if MFA is required
  if (tokens.expires_in === 300) {
    // This is a pre-auth token (5 minute expiration)
    console.log('MFA verification required');

    // Prompt user for MFA code
    const mfaCode = await promptForMfaCode();

    // Step 3: Verify MFA code
    // The SDK automatically saves the full session after successful verification
    const fullTokens = await sso.auth.verifyMfa(tokens.access_token, mfaCode);

    return fullTokens;
  }

  // No MFA - normal login
  // Session is already automatically saved during login
  return tokens;
}

Complete Login Form with MFA Support

import { useState } from 'react';
import { SsoClient, SsoApiError } from '@drmhse/sso-sdk';

const sso = new SsoClient({
  baseURL: process.env.REACT_APP_SSO_URL
});

export function LoginWithMfaForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [mfaCode, setMfaCode] = useState('');
  const [preauthToken, setPreauthToken] = useState<string | null>(null);
  const [useBackupCode, setUseBackupCode] = useState(false);
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);

  const handlePasswordLogin = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
    setLoading(true);

    try {
      const tokens = await sso.auth.login({ email, password });

      // Check if MFA verification is required
      if (tokens.expires_in === 300) {
        setPreauthToken(tokens.access_token);
        setLoading(false);
        return;
      }

      // No MFA - session automatically saved, proceed to dashboard
      window.location.href = '/dashboard';
    } catch (err) {
      if (err instanceof SsoApiError) {
        if (err.statusCode === 401) {
          setError('Invalid email or password');
        } else if (err.statusCode === 403) {
          setError('Please verify your email before logging in');
        } else {
          setError(err.message);
        }
      } else {
        setError('Login failed. Please try again.');
      }
      setLoading(false);
    }
  };

  const handleMfaVerification = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
    setLoading(true);

    try {
      // MFA verification automatically saves the full session
      await sso.auth.verifyMfa(preauthToken!, mfaCode);

      // Session is now authenticated, redirect to dashboard
      window.location.href = '/dashboard';
    } catch (err) {
      if (err instanceof SsoApiError) {
        setError('Invalid code. Please try again.');
      } else {
        setError('Verification failed. Please try again.');
      }
      setLoading(false);
    }
  };

  // Show MFA verification form
  if (preauthToken) {
    return (
      <form onSubmit={handleMfaVerification}>
        <h2>Two-Factor Authentication</h2>

        <p>
          {useBackupCode
            ? 'Enter one of your backup codes'
            : 'Enter the 6-digit code from your authenticator app'}
        </p>

        <div className="form-group">
          <input
            type="text"
            value={mfaCode}
            onChange={(e) => {
              if (useBackupCode) {
                setMfaCode(e.target.value);
              } else {
                setMfaCode(e.target.value.replace(/\D/g, ''));
              }
            }}
            maxLength={useBackupCode ? 20 : 6}
            placeholder={useBackupCode ? 'XXXX-XXXX-XXXX' : '000000'}
            required
            autoFocus
          />
        </div>

        {error && <div className="error">{error}</div>}

        <button type="submit" disabled={loading}>
          {loading ? 'Verifying...' : 'Verify'}
        </button>

        <div className="mfa-options">
          <button
            type="button"
            onClick={() => {
              setUseBackupCode(!useBackupCode);
              setMfaCode('');
              setError('');
            }}
          >
            {useBackupCode ? 'Use authenticator code' : 'Use backup code'}
          </button>

          <button
            type="button"
            onClick={() => {
              setPreauthToken(null);
              setMfaCode('');
              setUseBackupCode(false);
              setError('');
            }}
          >
            Back to Login
          </button>
        </div>
      </form>
    );
  }

  // Show password login form
  return (
    <form onSubmit={handlePasswordLogin}>
      <h2>Sign In</h2>

      <div className="form-group">
        <label htmlFor="email">Email Address</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
        />
      </div>

      <div className="form-group">
        <label htmlFor="password">Password</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
        />
      </div>

      {error && <div className="error">{error}</div>}

      <button type="submit" disabled={loading}>
        {loading ? 'Signing In...' : 'Sign In'}
      </button>
    </form>
  );
}

Managing Backup Codes

Backup codes allow users to access their account if they lose their authenticator device. Each user receives 10 backup codes during MFA setup.

Regenerating Backup Codes

Users can regenerate backup codes at any time. This invalidates all previous backup codes.

async function regenerateBackupCodes() {
  try {
    const result = await sso.user.mfa.regenerateBackupCodes();

    console.log('New backup codes:', result.backup_codes);

    // IMPORTANT: Display new codes to user
    // Previous codes are now invalid
    return result.backup_codes;
  } catch (error) {
    console.error('Failed to regenerate backup codes:', error);
    throw error;
  }
}

Backup Codes Management Component

import { useState } from 'react';
import { SsoClient, SsoApiError } from '@drmhse/sso-sdk';

interface BackupCodesManagerProps {
  sso: SsoClient;
}

export function BackupCodesManager({ sso }: BackupCodesManagerProps) {
  const [codes, setCodes] = useState<string[]>([]);
  const [showCodes, setShowCodes] = useState(false);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');

  const regenerateCodes = async () => {
    const confirmed = window.confirm(
      'This will invalidate your current backup codes. Continue?'
    );

    if (!confirmed) return;

    setLoading(true);
    setError('');

    try {
      const result = await sso.user.mfa.regenerateBackupCodes();
      setCodes(result.backup_codes);
      setShowCodes(true);
    } catch (err) {
      if (err instanceof SsoApiError) {
        setError(err.message);
      } else {
        setError('Failed to regenerate backup codes');
      }
    } finally {
      setLoading(false);
    }
  };

  const downloadCodes = () => {
    const blob = new Blob([codes.join('\n')], { type: 'text/plain' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `backup-codes-${Date.now()}.txt`;
    a.click();
    URL.revokeObjectURL(url);
  };

  const copyCodes = () => {
    navigator.clipboard.writeText(codes.join('\n'));
  };

  return (
    <div className="backup-codes-manager">
      <h3>Backup Codes</h3>

      <p>
        Backup codes allow you to access your account if you lose your
        authenticator device. Each code can only be used once.
      </p>

      {showCodes && codes.length > 0 && (
        <div className="codes-display">
          <div className="warning">
            <strong>Save these codes now!</strong> They won't be shown again.
          </div>

          <div className="codes-grid">
            {codes.map((code, index) => (
              <code key={index}>{code}</code>
            ))}
          </div>

          <div className="actions">
            <button onClick={copyCodes}>Copy All</button>
            <button onClick={downloadCodes}>Download</button>
            <button onClick={() => setShowCodes(false)}>Hide</button>
          </div>
        </div>
      )}

      {!showCodes && (
        <div className="regenerate-section">
          <p>
            Need new backup codes? Regenerating will invalidate all previous codes.
          </p>
          <button onClick={regenerateCodes} disabled={loading}>
            {loading ? 'Generating...' : 'Regenerate Backup Codes'}
          </button>
        </div>
      )}

      {error && <div className="error">{error}</div>}
    </div>
  );
}

Disabling MFA

Users can disable MFA at any time. This removes the TOTP requirement and invalidates all backup codes.

Disable MFA

async function disableMfa() {
  try {
    const result = await sso.user.mfa.disable();

    console.log(result.message);
    // "MFA disabled successfully"

    return true;
  } catch (error) {
    console.error('Failed to disable MFA:', error);
    return false;
  }
}

Disable MFA Component

import { useState } from 'react';
import { SsoClient, SsoApiError } from '@drmhse/sso-sdk';

interface DisableMfaProps {
  sso: SsoClient;
  onDisabled: () => void;
}

export function DisableMfa({ sso, onDisabled }: DisableMfaProps) {
  const [showConfirm, setShowConfirm] = useState(false);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');

  const handleDisable = async () => {
    setLoading(true);
    setError('');

    try {
      await sso.user.mfa.disable();
      onDisabled();
    } catch (err) {
      if (err instanceof SsoApiError) {
        setError(err.message);
      } else {
        setError('Failed to disable MFA');
      }
      setLoading(false);
    }
  };

  if (showConfirm) {
    return (
      <div className="disable-mfa-confirm">
        <h3>Disable Two-Factor Authentication?</h3>

        <div className="warning">
          <p>
            <strong>Warning:</strong> Disabling 2FA will make your account less secure.
          </p>
          <ul>
            <li>Your backup codes will be deleted</li>
            <li>You'll only need your password to sign in</li>
            <li>You can re-enable 2FA at any time</li>
          </ul>
        </div>

        {error && <div className="error">{error}</div>}

        <div className="actions">
          <button
            onClick={handleDisable}
            disabled={loading}
            className="danger"
          >
            {loading ? 'Disabling...' : 'Yes, Disable 2FA'}
          </button>
          <button onClick={() => setShowConfirm(false)}>
            Cancel
          </button>
        </div>
      </div>
    );
  }

  return (
    <div className="disable-mfa">
      <button
        onClick={() => setShowConfirm(true)}
        className="danger"
      >
        Disable Two-Factor Authentication
      </button>
    </div>
  );
}

Complete MFA Settings Page

Here’s a complete example combining all MFA management features:

import { useState, useEffect } from 'react';
import { SsoClient } from '@drmhse/sso-sdk';

interface MfaSettingsPageProps {
  sso: SsoClient;
}

export function MfaSettingsPage({ sso }: MfaSettingsPageProps) {
  const [mfaEnabled, setMfaEnabled] = useState<boolean | null>(null);
  const [loading, setLoading] = useState(true);
  const [showSetup, setShowSetup] = useState(false);

  useEffect(() => {
    checkMfaStatus();
  }, []);

  const checkMfaStatus = async () => {
    try {
      const status = await sso.user.mfa.getStatus();
      setMfaEnabled(status.enabled);
    } catch (error) {
      console.error('Failed to check MFA status:', error);
    } finally {
      setLoading(false);
    }
  };

  const handleSetupComplete = () => {
    setShowSetup(false);
    setMfaEnabled(true);
  };

  const handleDisabled = () => {
    setMfaEnabled(false);
  };

  if (loading) {
    return <div>Loading...</div>;
  }

  return (
    <div className="mfa-settings-page">
      <h1>Two-Factor Authentication</h1>

      {mfaEnabled === false && !showSetup && (
        <div className="mfa-disabled-state">
          <div className="info-card">
            <h2>Protect Your Account</h2>
            <p>
              Two-factor authentication adds an extra layer of security to your
              account by requiring both your password and a verification code
              from your phone.
            </p>
            <button onClick={() => setShowSetup(true)} className="primary">
              Enable Two-Factor Authentication
            </button>
          </div>
        </div>
      )}

      {mfaEnabled === false && showSetup && (
        <MfaSetupFlow
          sso={sso}
          onComplete={handleSetupComplete}
        />
      )}

      {mfaEnabled === true && (
        <div className="mfa-enabled-state">
          <div className="status-card success">
            <h2>Two-Factor Authentication is Enabled</h2>
            <p>Your account is protected with 2FA</p>
          </div>

          <div className="mfa-management">
            <BackupCodesManager sso={sso} />
            <DisableMfa sso={sso} onDisabled={handleDisabled} />
          </div>
        </div>
      )}
    </div>
  );
}

Authenticator App Integration

The SSO platform is compatible with all TOTP-based authenticator apps:

  • Google Authenticator (iOS/Android)
  • Authy (iOS/Android/Desktop)
  • Microsoft Authenticator (iOS/Android)
  • 1Password (iOS/Android/Desktop/Browser)
  • Bitwarden (iOS/Android/Desktop/Browser)
  • LastPass Authenticator (iOS/Android)

QR Code Format

The generated QR code contains an otpauth:// URL in this format:

otpauth://totp/SSO:user@example.com?secret=SECRETKEY&issuer=SSO

Parameters:

  • type: totp (Time-based OTP)
  • label: Service name and user identifier
  • secret: Base32-encoded TOTP secret
  • issuer: Platform name for identification in the app

Manual Entry

If users can’t scan the QR code, they can manually enter the secret key:

  1. Open authenticator app
  2. Select “Enter a setup key” or similar
  3. Enter account name (e.g., “SSO - user@example.com”)
  4. Enter the secret key (shown as plain text)
  5. Select “Time-based” as the type

Recovery Flows

Lost Authenticator Device

If a user loses their authenticator device, they have two options:

Option 1: Use Backup Code

// During login, user can enter a backup code instead of TOTP
const tokens = await sso.auth.verifyMfa(preauthToken, backupCode);

Option 2: Account Recovery If the user also lost their backup codes, they’ll need to:

  1. Contact support or admin
  2. Admin can disable MFA for the user
  3. User logs in with password only
  4. User can re-enable MFA with a new device

Lost Backup Codes

If users lose their backup codes but still have their authenticator device:

// User can regenerate backup codes while authenticated
const newCodes = await sso.user.mfa.regenerateBackupCodes();

Backup Code Usage

// Backup codes are single-use
// After using a code, it's marked as used and cannot be reused
const tokens = await sso.auth.verifyMfa(preauthToken, 'ABCD-EFGH-IJKL');

Best Practices

Security Considerations

  1. Secure Backup Code Storage: Users should store backup codes securely

    • Print and store in a safe place
    • Store in a password manager
    • Never share or post online
  2. Rate Limiting: The platform enforces rate limits on MFA verification attempts

  3. Pre-auth Token Expiration: Pre-auth tokens expire after 5 minutes

  4. Backup Code Regeneration: Warn users that regeneration invalidates old codes

  5. Account Recovery: Have a clear recovery process for users who lose access

UX Considerations

  1. Clear Instructions: Provide step-by-step setup instructions

  2. QR Code Alternatives: Always offer manual entry option

  3. Backup Code Emphasis: Make sure users save backup codes during setup

  4. Graceful Degradation: Handle errors gracefully and provide helpful messages

  5. Testing: Always test MFA flow thoroughly before production

Implementation Checklist

  • Check MFA status before showing enable/disable options
  • Display QR code clearly with manual entry alternative
  • Require verification before enabling MFA
  • Force users to acknowledge backup codes
  • Provide backup code download and copy options
  • Handle both TOTP codes and backup codes during login
  • Implement backup code regeneration
  • Add confirmation for MFA disable action
  • Test full flow including recovery scenarios
  • Document recovery process for users

Error Handling

import { SsoApiError } from '@drmhse/sso-sdk';

async function handleMfaError(error: unknown): Promise<string> {
  if (error instanceof SsoApiError) {
    switch (error.statusCode) {
      case 400:
        return 'Invalid MFA code format';
      case 401:
        return 'Invalid or expired code. Please try again.';
      case 409:
        return 'MFA is already enabled for this account';
      case 429:
        return 'Too many attempts. Please try again later.';
      case 500:
        return 'Server error. Please try again later.';
      default:
        return error.message;
    }
  }
  return 'An unexpected error occurred';
}

// Usage
try {
  await sso.user.mfa.verify(code);
} catch (error) {
  const message = await handleMfaError(error);
  showErrorToUser(message);
}

Next Steps