Skip to content

MFA (Multi-Factor Authentication)

Multi-Factor Authentication implementation for enhanced account security.

Overview

Our MFA implementation provides SMS-based two-factor authentication for user accounts. When users with MFA enabled attempt to sign in, they must verify their identity by entering a 6-digit code sent to their registered phone number.

When MFA is triggered: MFA is triggered during any Firebase authentication attempt (password-based or email link) when the user account has multi-factor authentication enabled. Firebase automatically throws an auth/multi-factor-auth-required error, which our application detects and handles by presenting MFA verification UI.

High-level user flow:

  1. User enters email and password (or clicks email link)
  2. Firebase checks if user has MFA enabled
  3. If enabled, Firebase returns MFA requirement with verification hints
  4. Application displays phone verification UI
  5. SMS code is sent to user’s registered phone
  6. User enters 6-digit code
  7. Code is verified, user is authenticated

Architecture

The MFA system is built in layers:

  1. Hooks Layer (Foundation) - Business logic and state management
  2. Components Layer - UI presentation and user interaction (detailed in PR #2)
  3. Integration Layer - Wiring into auth flow (detailed in PR #3)

Hooks Layer

useMfa Hook

The core MFA verification hook that manages all business logic:

Responsibilities:

  • reCAPTCHA lifecycle management - Initializes and cleans up Google reCAPTCHA verifier
  • SMS code sending - Uses Firebase PhoneAuthProvider to send verification codes
  • Code verification - Verifies user-entered codes against Firebase
  • State machine - Tracks verification flow (idle β†’ sending β†’ verifying β†’ success/error)
  • Error mapping - Converts Firebase errors to user-friendly error codes

Key Features:

  • Resend functionality with 30-second countdown
  • Automatic error categorization (user errors vs system errors)
  • Phone number extraction from MFA hints
  • Cleanup on unmount to prevent memory leaks

useSimpleAuth Hook

Authentication state management with MFA detection:

Responsibilities:

  • Monitors Firebase onAuthStateChanged events
  • Detects MFA requirements via detectMfaRequest() function
  • Provides mfaResolver to MFA components
  • Tracks authentication status (INITIALIZING, AUTHENTICATED, MFA_REQUIRED, etc.)

See: useSimpleAuth Documentation for complete details

Hook Interaction

Sign-in Error
↓
useSimpleAuth.detectMfaRequest() ← Checks error code
↓
State: MFA_REQUIRED
↓
Provides mfaResolver
↓
useMfa uses resolver ← Sends SMS, verifies code
↓
Firebase onAuthStateChanged
↓
useSimpleAuth updates to AUTHENTICATED_VIA_MFA

Components Layer

The MFA components provide the UI presentation layer for the verification flow.

Component Architecture

ComponentResponsibilityKey Features
MfaVerificationFormOrchestrator component- Filters to supported methods (Phone/SMS only)
- Auto-selects if single factor
- Manages step transitions
- Shows completion state
MfaPhoneVerificationSMS verification UI- Auto-sends SMS on mount
- 6-digit code input with validation
- 30-second resend countdown
- Real-time error display
MfaFactorSelectionFactor selection UI- Radio button selection
- Phone number masking
- Only shown if multiple factors
messages.jsi18n translations- All UI text
- All error messages (keys match MFA_ERROR values)

MfaVerificationForm

Purpose: Top-level orchestrator that manages the entire MFA flow.

Props:

type Props = {|
mfaResolver: ?$npm$firebase$auth$MultiFactorResolver,
onCancel?: () => void,
onError?: (error: Error) => void,
|};

Flow:

  1. Extracts hints from mfaResolver
  2. Filters to only Phone/SMS factors (TOTP and others not supported)
  3. Auto-selection: If only one factor, skips selection and goes directly to verification
  4. Multi-factor: If multiple factors, shows MfaFactorSelection
  5. Verification: Renders MfaPhoneVerification with selected hint
  6. Completion: Shows success state with LinearProgress
  7. Error state: Shows error UI if no factors available

Key Feature - Auto-selection:

useEffect(() => {
if (mfaHints.length === 1) {
setSelectedHint(mfaHints[0]);
setStep('verifying'); // Skip selection step
}
}, [mfaHints]);

State Management:

  • step: β€˜selecting’ | β€˜verifying’ | β€˜completed’
  • selectedHint: Currently selected MFA factor

MfaPhoneVerification

Purpose: UI for SMS-based verification with automatic sending and countdown timer.

Props:

type Props = {|
mfaResolver: $npm$firebase$auth$MultiFactorResolver,
selectedHint: $npm$firebase$auth$MultiFactorInfo,
onBack: () => void,
onError?: (error: Error) => void,
onSuccess?: () => void,
|};

Features:

  1. Automatic SMS Sending:

    • Initializes reCAPTCHA on mount
    • Automatically sends SMS (no button click needed)
    • Shows LinearProgress during send
  2. Code Input:

    • Auto-focus on mount
    • Numeric input mode (mobile keyboard)
    • Max 6 digits with validation
    • Enter key submits
    • Clears error on typing
  3. Resend Flow:

    • 30-second countdown timer
    • Button disabled during countdown
    • Resets countdown on resend
    • Clears input field
  4. Error Display:

    • Alert component for errors
    • Auto-clears when user types
    • Error messages from messages.js
  5. State Sync:

    • Uses useMfa hook for business logic
    • Reacts to hook status changes
    • Calls onSuccess() when verification completes

Key Implementation - Auto-send:

useEffect(() => {
const cleanup = initRecaptcha('mfa-recaptcha-container');
// Send SMS immediately after reCAPTCHA ready
sendVerificationCode().catch((err) => {
if (onError) onError(err);
});
return cleanup;
}, []); // Run once on mount

Key Implementation - Countdown:

// Start countdown when form appears
useEffect(() => {
if (isVerifying && !resendAvailableAt) {
setResendAvailableAt(Date.now() + 30000);
}
}, [isVerifying, resendAvailableAt]);
// Update every second
useEffect(() => {
if (isVerifying && resendAvailableAt) {
const timerId = setInterval(() => {
const remaining = Math.max(0, Math.ceil((resendAvailableAt - Date.now()) / 1000));
setResendCountdown(remaining);
}, 1000);
return () => clearInterval(timerId);
}
}, [isVerifying, resendAvailableAt]);

MfaFactorSelection

Purpose: Selection UI when multiple MFA methods are available.

Props:

type Props = {|
hints: Array<$npm$firebase$auth$MultiFactorInfo>,
onSelect: (hint: $npm$firebase$auth$MultiFactorInfo) => void,
onCancel: () => void,
|};

Features:

  • Radio button list of available methods
  • Shows phone numbers (masked)
  • Defaults to first option
  • Continue/Cancel buttons

Note: Currently only receives Phone/SMS factors (filtered by parent), but structure allows for future TOTP support.

UI Flow

Single Factor Flow (Most Common):

User signs in
↓
Firebase throws MFA error
↓
MfaVerificationForm mounts
↓
Auto-selects single factor ← No selection UI shown
↓
MfaPhoneVerification mounts
↓
Auto-sends SMS ← Immediate, no button click
↓
Shows code input (6 digits)
↓
User enters code + clicks Verify
↓
Shows success message + loading
↓
Firebase onAuthStateChanged fires
↓
App redirects to intended destination

Multiple Factors Flow (Rare):

User signs in
↓
Firebase throws MFA error
↓
MfaVerificationForm mounts
↓
Shows MfaFactorSelection ← Radio list
↓
User selects factor + clicks Continue
↓
MfaPhoneVerification mounts
↓
(continues as single factor flow above)

Error Recovery Flow:

Code verification fails
↓
Alert shows error message
↓
User can:
β”œβ”€β†’ Retry entering code (error clears on typing)
β”œβ”€β†’ Wait 30s and click Resend
└─→ Click Back (returns to selection or cancels)

Integration Layer

MFA is integrated into three sign-in flows:

  1. Password-based sign-in (views/SignIn/index.js)
  2. Email confirmation sign-in (pages/sign-in-confirmation.js)
  3. Email link sign-in (pages/sign-in-email.js)

All three follow the same integration pattern:

  • Use useSimpleAuth hook for MFA detection
  • Catch Firebase errors with detectMfaRequest()
  • Show MfaVerificationForm when MFA is required
  • Continue normal flow after MFA completion

Integration Pattern

Step 1: Import dependencies

import useSimpleAuth from '../../hooks/auth/useSimpleAuth';
import MfaVerificationForm from '../../components/Auth/Mfa/MfaVerificationForm';
import { AUTH_STATUS } from '../../hooks/auth/auth';

Step 2: Get MFA state from hook

const {
detectMfaRequest, // Function to check if error is MFA request
mfaRequired, // Boolean: true when MFA challenge active
mfaResolver, // Firebase resolver for MFA completion
status, // Auth status (AUTHENTICATED_VIA_MFA after success)
} = useSimpleAuth();

Step 3: Detect MFA in error handler

firebase.auth().signInWithEmailAndPassword(email, password)
.then(/* handle success */)
.catch((error) => {
// Check for MFA requirement first
if (detectMfaRequest(error)) {
// Exit here - MFA form will be shown automatically
return;
}
// Handle other errors
// ...
});

Step 4: Conditionally show MFA form

const showMfaForm = mfaRequired || status === AUTH_STATUS.AUTHENTICATED_VIA_MFA;
return (
<>
{!showMfaForm && (
{/* Normal sign-in UI */}
)}
{showMfaForm && (
<MfaVerificationForm
mfaResolver={mfaResolver}
onCancel={() => window.location.reload()}
onError={(error) => {
enqueueSnackbar(error.message, { variant: 'error' });
}}
/>
)}
</>
);

Integration in views/SignIn/index.js

Purpose: Password-based sign-in with MFA support

Key Changes:

  1. Import useSimpleAuth, MfaVerificationForm, AUTH_STATUS
  2. Get MFA state: const { detectMfaRequest, mfaRequired, mfaResolver, status } = useSimpleAuth();
  3. In handleSignIn, catch errors and check detectMfaRequest(error) before handling other errors
  4. Update showLoginForm logic: hide when mfaRequired or isAuthenticated
  5. Add showMfaForm condition: mfaRequired || (!companies && status === AUTH_STATUS.AUTHENTICATED_VIA_MFA)
  6. Render MfaVerificationForm when showMfaForm is true

Flow:

User enters password + clicks Sign In
↓
handleSignIn() calls firebase.auth().signInWithEmailAndPassword()
↓
Firebase throws auth/multi-factor-auth-required
↓
detectMfaRequest(error) returns true
↓
useSimpleAuth sets mfaRequired=true, stores mfaResolver
↓
Component re-renders with showMfaForm=true
↓
MfaVerificationForm appears
↓
User completes MFA verification
↓
Firebase onAuthStateChanged fires
↓
useSimpleAuth sets status=AUTHENTICATED_VIA_MFA
↓
Normal post-auth flow continues (company selection)

Integration in pages/sign-in-confirmation.js

Purpose: Email link sign-in with MFA support

Key Changes:

  1. Import useSimpleAuth, MfaVerificationForm, AUTH_STATUS
  2. Get MFA state: const { detectMfaRequest, mfaRequired, mfaResolver, status } = useSimpleAuth();
  3. Extract verifyUser() into separate callback (called after Firebase auth)
  4. In init() (email link sign-in), catch errors with detectMfaRequest(error)
  5. Add useEffect to watch for AUTHENTICATED_VIA_MFA status and call verifyUser()
  6. Update UI to hide email form when MFA is active
  7. Render MfaVerificationForm when showMfaForm is true

Flow:

User clicks email link
↓
init() calls firebase.auth().signInWithEmailLink()
↓
Firebase throws auth/multi-factor-auth-required
↓
detectMfaRequest(error) returns true
↓
useSimpleAuth sets mfaRequired=true, stores mfaResolver
↓
Component re-renders with showMfaForm=true
↓
MfaVerificationForm appears
↓
User completes MFA verification
↓
Firebase onAuthStateChanged fires
↓
useSimpleAuth sets status=AUTHENTICATED_VIA_MFA
↓
useEffect detects AUTHENTICATED_VIA_MFA
↓
verifyUser() called to fetch companies from backend
↓
Company selection screen appears

Integration in pages/sign-in-email.js

Purpose: Email link sign-in (magic link) with MFA support

Key Changes:

  1. Import useSimpleAuth, MfaVerificationForm, AUTH_STATUS
  2. Get MFA state: const { detectMfaRequest, mfaRequired, mfaResolver, status } = useSimpleAuth();
  3. In sign-in handler, catch Firebase errors with detectMfaRequest(error)
  4. Add useEffect to watch for AUTHENTICATED_VIA_MFA status and trigger post-auth flow
  5. Update UI to hide email input form when MFA is active
  6. Render MfaVerificationForm when showMfaForm is true

Flow:

User enters email and requests magic link
↓
User clicks magic link from email
↓
Firebase authenticates with email link
↓
Firebase throws auth/multi-factor-auth-required
↓
detectMfaRequest(error) returns true
↓
useSimpleAuth sets mfaRequired=true, stores mfaResolver
↓
Component re-renders with showMfaForm=true
↓
MfaVerificationForm appears
↓
User completes MFA verification
↓
Firebase onAuthStateChanged fires
↓
useSimpleAuth sets status=AUTHENTICATED_VIA_MFA
↓
useEffect detects AUTHENTICATED_VIA_MFA
↓
Post-auth flow continues (redirect to app)
↓
User is signed in

Key Differences from sign-in-confirmation.js:

  • Different user journey (magic link vs confirmation)
  • Different post-auth behavior (redirect vs company selection)
  • Simpler UI state management

Common Implementation Details

Cancel Handler: Both integrations reload the page on cancel:

onCancel={() => {
if (typeof window !== 'undefined') {
window.location.reload();
}
}}

This resets the entire sign-in flow, clearing MFA state and returning user to the sign-in form.

Error Handler:

onError={(error) => {
enqueueSnackbar(error.message, { variant: 'error' });
}}

Displays MFA errors using the app’s snackbar system.

Conditional Rendering Logic:

// Hide normal sign-in form during MFA
const showLoginForm = (
!companies &&
!showChangePassword &&
!showForgot &&
!resultMessage &&
!mfaRequired && // Hide during MFA challenge
!isAuthenticated // Hide when authenticated
);
// Show MFA form during challenge or right after success
const showMfaForm = (
mfaRequired || // During MFA challenge
(!companies && status === AUTH_STATUS.AUTHENTICATED_VIA_MFA) // After MFA, before companies loaded
);

Backend Verification Timing:

  • Without MFA: Backend verification happens immediately after signInWithEmailAndPassword resolves
  • With MFA: Backend verification waits until after MFA completion (when status === AUTHENTICATED_VIA_MFA)

This ensures the backend only processes fully-authenticated users.

State Machine

The useMfa hook implements a state machine to track the verification flow:

StateDescriptionTriggers
idleInitial state, ready to send SMSOn mount, after resend button clicked
sendingSending SMS verification codesendVerificationCode() called
verifyingWaiting for user to enter codeSMS sent successfully, showing code input
successVerification completed successfullyFirebase confirms code is valid
errorVerification failedAny error during send or verification

State Transitions

idle
↓ sendVerificationCode()
sending
β”œβ”€β†’ verifying (SMS sent successfully)
└─→ error (SMS send failed)
verifying
β”œβ”€β†’ success (code verified)
β”œβ”€β†’ error (verification failed)
└─→ sending (resend clicked)
error
└─→ idle or sending (retry/resend)

Derived Boolean Helpers

The hook provides these convenience booleans:

  • isLoading = status === β€˜sending’
  • isVerifying = status === β€˜verifying’
  • isSuccess = status === β€˜success’
  • isError = status === β€˜error’

Error Handling

Error Code System

MFA errors use structured error codes instead of free-text messages for better maintainability and i18n support:

Why error codes?

  • Separation of concerns: Business logic (hook) returns codes, UI (component) handles translation
  • Type safety: Flow types ensure only valid error codes are used
  • Selective Sentry logging: Categorize errors to avoid logging expected user behavior
  • Consistent UX: Same error always shows same message across app

Flow:

Firebase Error β†’ useMfa maps to MFA_ERROR code β†’ Component translates via react-intl β†’ User sees localized message

Error Categories

All 17 MFA error codes organized by category:

User Errors (Expected, NOT logged to Sentry)

CodeDescriptionWhen It Occurs
mfaInvalidCodeUser entered wrong verification codeCommon typo or wrong code
mfaCodeExpiredUser took too long (>5 min)Code expired, needs resend
mfaTooManyAttemptsRate limiting triggeredToo many failed attempts
mfaSessionExpiredMFA session timed outUser idle too long
mfaInvalidCodeLengthCode not 6 digits (client validation)Caught before Firebase call

Configuration Errors (System issues, logged to Sentry)

CodeDescriptionWhen It Occurs
mfaQuotaExceededSMS quota limit reachedDaily/monthly limit hit
mfaOperationNotAllowedPhone auth/MFA disabledFirebase console misconfigured
mfaInvalidAppCredentialFirebase config issueApp credentials problem
mfaCaptchaCheckFailedreCAPTCHA server-side failurereCAPTCHA service down
mfaInvalidPhoneNumberInvalid phone (shouldn’t happen with hints)Data corruption
mfaMultiFactorInfoNotFoundMFA factor missing from accountAccount data issue

State Errors (Internal bugs, logged to Sentry)

CodeDescriptionWhen It Occurs
mfaMissingParametersMissing resolver/hintHook called incorrectly
mfaRecaptchaNotInitializedreCAPTCHA not initializedinitRecaptcha() not called
mfaNoVerificationInProgressverifyCode() before sendCode()Incorrect flow
mfaInvalidVerificationIdVerification ID corruptedState management bug
mfaRecaptchaFailedreCAPTCHA error callbackreCAPTCHA internal error

Generic Fallbacks (Unknown errors, logged to Sentry)

CodeDescriptionWhen It Occurs
mfaSendFailedGeneric SMS send errorUnmapped Firebase error
mfaVerificationFailedGeneric verification errorUnmapped verification error

Adding a New Error Code

If you encounter a new Firebase error that needs handling:

  1. Add constant to MFA_ERROR in hooks/auth/auth.js with descriptive camelCase name
  2. Add to appropriate category comment (User/Configuration/State/Generic)
  3. Add translation message to components/Auth/Mfa/messages.js with key matching the constant value
  4. Include description for translators explaining when this error occurs
  5. Update shouldLogMfaErrorToSentry() if it’s a user error (don’t log to Sentry)
  6. Map Firebase error in useMfa.js error mapping functions

Testing

Local Development Testing

Prerequisites:

  1. Firebase project with MFA enabled
  2. Test user account with MFA factor enrolled
  3. reCAPTCHA configured for localhost

Testing Password Sign-In:

  1. Navigate to /sign-in
  2. Enter email/password for MFA-enabled account
  3. Verify MFA form appears automatically
  4. Check that SMS is sent immediately
  5. Enter 6-digit code and verify
  6. Confirm redirect to company selection

Testing Email Link Sign-In:

  1. Request email link for MFA-enabled account
  2. Click link in email
  3. Verify MFA form appears after email confirmation
  4. Complete verification flow
  5. Confirm backend verification completes

Testing Error Cases:

  • Invalid code (wrong digits)
  • Expired code (wait >5 minutes)
  • Resend functionality (30-second cooldown)
  • Cancel button (page reload)
  • Network errors

Manual Test Cases

Test CaseStepsExpected Result
Single FactorSign in with MFA accountNo selection screen, direct to verification
Multiple FactorsSign in with multi-factor accountSelection screen appears first
Auto SMS SendMFA form loadsSMS sent immediately without button click
Code InputEnter 6 digitsVerify button enabled, Enter key works
Resend CountdownClick ResendButton disabled for 30s with countdown
Invalid CodeEnter wrong codeError alert, can retry
Cancel FlowClick CancelPage reloads, back to sign-in
Success FlowComplete verificationLoading indicator, then company selection

Troubleshooting

Common Issues

Issue: β€œreCAPTCHA not initialized”

  • Cause: initRecaptcha() not called before sendVerificationCode()
  • Solution: Check that useEffect in MfaPhoneVerification runs on mount

Issue: SMS not received

  • Cause: Firebase SMS quota exceeded or phone auth disabled
  • Check: Firebase Console β†’ Authentication β†’ Sign-in methods β†’ Phone enabled
  • Check: Firebase Console β†’ Usage β†’ SMS quota

Issue: β€œCode expired” immediately

  • Cause: Clock skew between client/server
  • Solution: Verify system time is correct

Issue: MFA form doesn’t appear

  • Cause: detectMfaRequest() not catching error
  • Solution: Check error code is auth/multi-factor-auth-required
  • Debug: Add console.log in .catch() handler

Issue: Stuck in MFA state after verification

  • Cause: onAuthStateChanged not firing
  • Solution: Check Firebase listener setup in useSimpleAuth

Issue: Backend verification not happening

  • Cause: AUTHENTICATED_VIA_MFA status not detected
  • Solution: Check useEffect dependencies in integration pages

Debugging Tips

Verify reCAPTCHA:

  • Open browser console
  • Look for reCAPTCHA widget loading
  • Check for CORS errors
  • Verify reCAPTCHA key matches Firebase project

  • useSimpleAuth Hook - MFA detection and auth state
  • hooks/auth/README.md - Quick reference for all authentication hooks
  • components/Auth/Mfa/README.md - MFA components overview