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:
- User enters email and password (or clicks email link)
- Firebase checks if user has MFA enabled
- If enabled, Firebase returns MFA requirement with verification hints
- Application displays phone verification UI
- SMS code is sent to userβs registered phone
- User enters 6-digit code
- Code is verified, user is authenticated
Architecture
The MFA system is built in layers:
- Hooks Layer (Foundation) - Business logic and state management
- Components Layer - UI presentation and user interaction (detailed in PR #2)
- 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
PhoneAuthProviderto 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
onAuthStateChangedevents - Detects MFA requirements via
detectMfaRequest()function - Provides
mfaResolverto 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_MFAComponents Layer
The MFA components provide the UI presentation layer for the verification flow.
Component Architecture
| Component | Responsibility | Key Features |
|---|---|---|
| MfaVerificationForm | Orchestrator component | - Filters to supported methods (Phone/SMS only) - Auto-selects if single factor - Manages step transitions - Shows completion state |
| MfaPhoneVerification | SMS verification UI | - Auto-sends SMS on mount - 6-digit code input with validation - 30-second resend countdown - Real-time error display |
| MfaFactorSelection | Factor selection UI | - Radio button selection - Phone number masking - Only shown if multiple factors |
| messages.js | i18n 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:
- Extracts hints from
mfaResolver - Filters to only Phone/SMS factors (TOTP and others not supported)
- Auto-selection: If only one factor, skips selection and goes directly to verification
- Multi-factor: If multiple factors, shows
MfaFactorSelection - Verification: Renders
MfaPhoneVerificationwith selected hint - Completion: Shows success state with LinearProgress
- 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:
-
Automatic SMS Sending:
- Initializes reCAPTCHA on mount
- Automatically sends SMS (no button click needed)
- Shows LinearProgress during send
-
Code Input:
- Auto-focus on mount
- Numeric input mode (mobile keyboard)
- Max 6 digits with validation
- Enter key submits
- Clears error on typing
-
Resend Flow:
- 30-second countdown timer
- Button disabled during countdown
- Resets countdown on resend
- Clears input field
-
Error Display:
- Alert component for errors
- Auto-clears when user types
- Error messages from
messages.js
-
State Sync:
- Uses
useMfahook for business logic - Reacts to hook status changes
- Calls
onSuccess()when verification completes
- Uses
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 mountKey Implementation - Countdown:
// Start countdown when form appearsuseEffect(() => { if (isVerifying && !resendAvailableAt) { setResendAvailableAt(Date.now() + 30000); }}, [isVerifying, resendAvailableAt]);
// Update every seconduseEffect(() => { 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 destinationMultiple 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:
- Password-based sign-in (
views/SignIn/index.js) - Email confirmation sign-in (
pages/sign-in-confirmation.js) - Email link sign-in (
pages/sign-in-email.js)
All three follow the same integration pattern:
- Use
useSimpleAuthhook for MFA detection - Catch Firebase errors with
detectMfaRequest() - Show
MfaVerificationFormwhen 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:
- Import
useSimpleAuth,MfaVerificationForm,AUTH_STATUS - Get MFA state:
const { detectMfaRequest, mfaRequired, mfaResolver, status } = useSimpleAuth(); - In
handleSignIn, catch errors and checkdetectMfaRequest(error)before handling other errors - Update
showLoginFormlogic: hide whenmfaRequiredorisAuthenticated - Add
showMfaFormcondition:mfaRequired || (!companies && status === AUTH_STATUS.AUTHENTICATED_VIA_MFA) - Render
MfaVerificationFormwhenshowMfaFormis 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:
- Import
useSimpleAuth,MfaVerificationForm,AUTH_STATUS - Get MFA state:
const { detectMfaRequest, mfaRequired, mfaResolver, status } = useSimpleAuth(); - Extract
verifyUser()into separate callback (called after Firebase auth) - In
init()(email link sign-in), catch errors withdetectMfaRequest(error) - Add
useEffectto watch forAUTHENTICATED_VIA_MFAstatus and callverifyUser() - Update UI to hide email form when MFA is active
- Render
MfaVerificationFormwhenshowMfaFormis 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 appearsIntegration in pages/sign-in-email.js
Purpose: Email link sign-in (magic link) with MFA support
Key Changes:
- Import
useSimpleAuth,MfaVerificationForm,AUTH_STATUS - Get MFA state:
const { detectMfaRequest, mfaRequired, mfaResolver, status } = useSimpleAuth(); - In sign-in handler, catch Firebase errors with
detectMfaRequest(error) - Add
useEffectto watch forAUTHENTICATED_VIA_MFAstatus and trigger post-auth flow - Update UI to hide email input form when MFA is active
- Render
MfaVerificationFormwhenshowMfaFormis 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 inKey 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 MFAconst showLoginForm = ( !companies && !showChangePassword && !showForgot && !resultMessage && !mfaRequired && // Hide during MFA challenge !isAuthenticated // Hide when authenticated);
// Show MFA form during challenge or right after successconst 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
signInWithEmailAndPasswordresolves - 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:
| State | Description | Triggers |
|---|---|---|
idle | Initial state, ready to send SMS | On mount, after resend button clicked |
sending | Sending SMS verification code | sendVerificationCode() called |
verifying | Waiting for user to enter code | SMS sent successfully, showing code input |
success | Verification completed successfully | Firebase confirms code is valid |
error | Verification failed | Any 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 messageError Categories
All 17 MFA error codes organized by category:
User Errors (Expected, NOT logged to Sentry)
| Code | Description | When It Occurs |
|---|---|---|
mfaInvalidCode | User entered wrong verification code | Common typo or wrong code |
mfaCodeExpired | User took too long (>5 min) | Code expired, needs resend |
mfaTooManyAttempts | Rate limiting triggered | Too many failed attempts |
mfaSessionExpired | MFA session timed out | User idle too long |
mfaInvalidCodeLength | Code not 6 digits (client validation) | Caught before Firebase call |
Configuration Errors (System issues, logged to Sentry)
| Code | Description | When It Occurs |
|---|---|---|
mfaQuotaExceeded | SMS quota limit reached | Daily/monthly limit hit |
mfaOperationNotAllowed | Phone auth/MFA disabled | Firebase console misconfigured |
mfaInvalidAppCredential | Firebase config issue | App credentials problem |
mfaCaptchaCheckFailed | reCAPTCHA server-side failure | reCAPTCHA service down |
mfaInvalidPhoneNumber | Invalid phone (shouldnβt happen with hints) | Data corruption |
mfaMultiFactorInfoNotFound | MFA factor missing from account | Account data issue |
State Errors (Internal bugs, logged to Sentry)
| Code | Description | When It Occurs |
|---|---|---|
mfaMissingParameters | Missing resolver/hint | Hook called incorrectly |
mfaRecaptchaNotInitialized | reCAPTCHA not initialized | initRecaptcha() not called |
mfaNoVerificationInProgress | verifyCode() before sendCode() | Incorrect flow |
mfaInvalidVerificationId | Verification ID corrupted | State management bug |
mfaRecaptchaFailed | reCAPTCHA error callback | reCAPTCHA internal error |
Generic Fallbacks (Unknown errors, logged to Sentry)
| Code | Description | When It Occurs |
|---|---|---|
mfaSendFailed | Generic SMS send error | Unmapped Firebase error |
mfaVerificationFailed | Generic verification error | Unmapped verification error |
Adding a New Error Code
If you encounter a new Firebase error that needs handling:
- Add constant to
MFA_ERRORinhooks/auth/auth.jswith descriptive camelCase name - Add to appropriate category comment (User/Configuration/State/Generic)
- Add translation message to
components/Auth/Mfa/messages.jswith key matching the constant value - Include description for translators explaining when this error occurs
- Update
shouldLogMfaErrorToSentry()if itβs a user error (donβt log to Sentry) - Map Firebase error in
useMfa.jserror mapping functions
Testing
Local Development Testing
Prerequisites:
- Firebase project with MFA enabled
- Test user account with MFA factor enrolled
- reCAPTCHA configured for localhost
Testing Password Sign-In:
- Navigate to
/sign-in - Enter email/password for MFA-enabled account
- Verify MFA form appears automatically
- Check that SMS is sent immediately
- Enter 6-digit code and verify
- Confirm redirect to company selection
Testing Email Link Sign-In:
- Request email link for MFA-enabled account
- Click link in email
- Verify MFA form appears after email confirmation
- Complete verification flow
- 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 Case | Steps | Expected Result |
|---|---|---|
| Single Factor | Sign in with MFA account | No selection screen, direct to verification |
| Multiple Factors | Sign in with multi-factor account | Selection screen appears first |
| Auto SMS Send | MFA form loads | SMS sent immediately without button click |
| Code Input | Enter 6 digits | Verify button enabled, Enter key works |
| Resend Countdown | Click Resend | Button disabled for 30s with countdown |
| Invalid Code | Enter wrong code | Error alert, can retry |
| Cancel Flow | Click Cancel | Page reloads, back to sign-in |
| Success Flow | Complete verification | Loading indicator, then company selection |
Troubleshooting
Common Issues
Issue: βreCAPTCHA not initializedβ
- Cause:
initRecaptcha()not called beforesendVerificationCode() - Solution: Check that
useEffectinMfaPhoneVerificationruns 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:
onAuthStateChangednot firing - Solution: Check Firebase listener setup in
useSimpleAuth
Issue: Backend verification not happening
- Cause:
AUTHENTICATED_VIA_MFAstatus not detected - Solution: Check
useEffectdependencies 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
Related Documentation
- useSimpleAuth Hook - MFA detection and auth state
hooks/auth/README.md- Quick reference for all authentication hookscomponents/Auth/Mfa/README.md- MFA components overview