How to Test Two-Factor Authentication on React Native (Complete Guide)

How to Test Two-Factor Authentication on React Native (Complete Guide)

January 14, 2026 · 18 min read · How-To Guides

How to Test Two-Factor Authentication on React Native (Complete Guide)

Testing two-factor authentication (2FA) in a React Native application is not just a checkbox; it is a critical security gate that protects user accounts from credential stuffing, phishing, and account takeover. When 2FA flows break in production, users are locked out, support costs rise, and trust erodes. This guide walks you through why 2FA matters, what typically fails, a detailed test matrix, manual and automated approaches, real‑world code examples, and how autonomous, persona‑driven exploration can surface bugs that scripted tests miss.

Why Two-Factor Authentication Matters in React Native Apps

React Native apps often reuse the same authentication backend as their web counterparts, but the mobile client introduces unique attack surfaces. Push notifications, biometric fallbacks, and deep‑link handling can all interfere with the delivery or verification of a second factor. If the 2FA code entry screen is poorly designed, users may mistype codes, triggering lockouts. If the fallback to SMS or authenticator apps is misconfigured, attackers can intercept or replay tokens. A robust 2FA implementation must therefore satisfy three goals:

  1. Correct delivery – the chosen second factor (SMS, email, authenticator app, push) reaches the user’s device within an expected time window.
  2. Accurate verification – the server validates the token, handles clock drift for TOTP, and enforces rate limits.
  3. Graceful degradation – when a factor is unavailable, the app offers a clear alternative without weakening security.

Failure in any of these areas leads to user frustration, increased support tickets, and potential compliance violations (e.g., GDPR, PSD2). Understanding these failure modes is the first step to building an effective test strategy.

Common Failure Modes of 2FA in Production

Before designing tests, it helps to catalog the ways 2FA can break in a live React Native app. The following list aggregates issues observed across multiple production releases:

Failure CategoryTypical SymptomRoot Cause
Delivery delayUser waits >30 s for SMS/code, then abandonsCarrier throttling, misconfigured Twilio/Nexmo provider, missing retry logic
Code mismatchVerification fails despite correct entryServer‑side TOTP window too narrow, device time skew >30 s
UI blockingModal prevents background app switch, user cannot copy code from authenticator appModal presented with animationType="none" and transparent={false}
Deep‑link hijackingClicking a verification link opens the wrong screen or a web viewImproper handling of Linking.addEventListener('url', …)
Fallback loopAfter SMS failure, app repeatedly prompts for SMS without offering authenticator optionState machine not resetting after error
Accessibility breachTalkBack/VoiceOver cannot focus the code input fieldMissing accessibilityLabel or accessibilityTraits
Rate‑limit bypassAttacker can submit unlimited wrong codesMissing exponential back‑off on the client or server
Credential leakageCode appears in logs or screenshotsDebug console.log statements left in release builds

Each of these items maps to one or more test scenarios in the matrix that follows.

Building a Comprehensive Test Matrix for 2FA

A test matrix ensures you cover happy paths, error paths, accessibility, and security angles. Below is a detailed matrix you can adapt to your own project. Each row represents a test case; columns indicate the test type, expected outcome, and notes on automation feasibility.

IDScenarioDescriptionExpected ResultManual?Automated? (Unit/Integration/E2E)Notes
1Happy‑path SMSUser enters phone number, receives SMS, inputs correct 6‑digit codeLogin succeeds, token stored✅ (E2E)Use a test Twilio number that returns a fixed code
2Happy‑path TOTPUser scans QR code, generates code in Google Authenticator, enters itLogin succeeds✅ (E2E)Mock time‑shift library to control TOTP window
3SMS delivery delay >30 sSimulate carrier latencyApp shows “Resend code” after timeout, does not block UI✅ (E2E with mock network)Use react-native‑netinfo to simulate high latency
4Incorrect TOTP (clock skew)Device time set 2 minutes aheadVerification fails, shows “Invalid code”✅ (Unit)Adjust jest‑fake‑timers
5Missing fallback optionSMS fails, authenticator option not presentedUser sees error and a button to try authenticator✅ (Integration)Verify state machine transitions
6Accessibility – TalkBack focusTalkBack enabled, navigate to code inputInput receives focus, label announced❌ (Manual)Requires device or emulator with TalkBack
7Rate‑limit enforcementFive consecutive wrong codesAfter fifth attempt, app shows “Too many attempts, try again in 5 min”✅ (E2E)Verify back‑off timer UI
8Deep‑link verificationUser clicks verification link from emailApp opens directly to verification screen with pre‑filled token✅ (E2E)Test with Linking.openURL
9Screen capture preventionUser attempts screenshot on code screenOS blocks screenshot or shows blank (if FLAG_SECURE used)❌ (Manual)Platform‑specific; verify via adb
10Leak in logsDebug build logs code to consoleNo code appears in adb logcat or Xcode console✅ (Unit)Scan build artifacts for console.log
11Biometric fallbackAfter SMS failure, user opts for fingerprintBiometric prompt appears, successful auth grants access✅ (E2E with react-native‑touch-id)Only on devices with fingerprint
12Expired push notificationPush notification expires before user actsApp shows “Notification expired, request new”✅ (E2E)Use Firebase Cloud Messaging with TTL
13Network loss mid‑flowUser loses internet after receiving codeApp queues verification, retries on reconnect✅ (E2E)Simulate with npm i -g clumsy or netsh
14International number formattingUser enters +44 7911 123456App normalizes to E.164, sends SMS correctly✅ (Unit)Test libphonenumber-js
15Adverse persona – impatient userUser taps “Resend” twice quicklyApp ignores second tap, shows cooldown✅ (E2E)Simulate rapid taps with Detox

This matrix gives you a concrete backlog. The next sections show how to execute each category manually and then how to automate the repeatable parts.

Manual Testing Approach Step‑by‑Step

Even with strong automation, manual exploratory testing remains vital for uncovering UX friction, accessibility problems, and edge cases that depend on human behavior. Follow this procedure for each major release or when you suspect a regression.

  1. Prepare test accounts
  1. Configure device or emulator
  1. Execute happy‑path scenarios
  1. Inject failures
  1. Accessibility checks
  1. Security and privacy checks
  1. Adverse persona simulation
  1. Record observations

Manual testing catches nuanced problems like confusing wording, missing accessibility labels, or unexpected UI animations that automated scripts often gloss over. However, repeating the same steps for every build is tedious, which leads us to automated approaches.

Automated Testing with React Native Specific Tools

Automation provides confidence that core 2FA logic remains intact across CI pipelines. React Native’s ecosystem offers several layers: unit tests for pure functions, integration tests for navigation and state, and end‑to‑end (E2E) tests that interact with the rendered UI. Below we detail each layer with concrete examples.

Unit and Integration Tests with Jest + React Native Testing Library

Start by isolating the logic that handles code verification, time‑window validation, and state transitions. Jest coupled with @testing-library/react-native lets you render components without a device.


// __tests__/TwoFactorVerification.test.js
import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react-native';
import TwoFactorVerification from '../src/components/TwoFactorVerification';
import * as authService from '../src/services/authService';

jest.mock('../src/services/authService');

describe('TwoFactorVerification', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  test('submits correct code and navigates to home', async () => {
    authService.verifyCode.mockResolvedValueOnce({ success: true });

    const { getByLabelText, getByRole } = render(<TwoFactorVerification />);
    const codeInput = getByLabelText(/enter verification code/i);
    const submitBtn = getByRole('button', { name: /verify/i });

    fireEvent.changeText(codeInput, '123456');
    fireEvent.press(submitBtn);

    await waitFor(() => expect(authService.verifyCode).toHaveBeenCalledWith('123456'));
    // Assuming the component uses navigation prop to redirect
    expect(getByRole('heading', { name: /home/i })).toBeTruthy();
  });

  test('shows error on invalid code', async () => {
    authService.verifyCode.mockResolvedValueOnce({ success: false, message: 'Invalid code' });

    const { getByLabelText, getByRole } = render(<TwoFactorVerification />);
    const codeInput = getByLabelText(/enter verification code/i);
    const submitBtn = getByRole('button', { name: /verify/i });

    fireEvent.changeText(codeInput, '000000');
    fireEvent.press(submitBtn);

    const errorMsg = await waitFor(() => getByRole('alert'));
    expect(errorMsg).toHaveTextContent(/invalid code/i);
  });

  test('enforces rate limit after five failures', async () => {
    authService.verifyCode.mockResolvedValueOnce({ success: false, message: 'Too many attempts' });

    const { getByLabelText, getByRole } = render(<TwoFactorVerification />);
    const codeInput = getByLabelText(/enter verification code/i);
    const submitBtn = getByRole('button', { name: /verify/i });

    for (let i = 0; i < 5; i++) {
      fireEvent.changeText(codeInput, '000000');
      fireEvent.press(submitBtn);
    }

    const errorMsg = await waitFor(() => getByRole('alert'));
    expect(errorMsg).toHaveTextContent(/too many attempts/i);
    // Verify that submit button is disabled
    expect(submitBtn).toHaveProp('disabled', true);
  });
});

What this covers

Run these tests in CI with npm test or yarn test. They execute in milliseconds and provide fast feedback on logic changes.

End‑to‑End Tests with Detox

Detox drives the actual native UI on emulators or real devices, making it ideal for validating the full 2FA flow, including native modules like SMS retrieval or biometric prompts. The following example demonstrates a happy‑path SMS test and a failure‑injection test using a mocked network layer.

First, add Detox to your project:


npm install --save-dev detox
detox init -r jest

Configure detox.config.js for Android emulator:


module.exports = {
  testRunner: 'jest',
  apps: {
    android.debug: {
      type: 'android.apk',
      binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
      build: 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug && cd ..',
    },
  },
  devices: {
    simulator: {
      type: 'android.emulator',
      device: {
        avdName: 'pixel_5_api_33',
      },
    },
  },
  configurations: {
    'android.debug': {
      device: 'simulator',
      app: 'apps.android.debug',
    },
  },
};

Now write the test spec:


// e2e/twoFactor.test.js
describe('Two-Factor Authentication Flow', () => {
  beforeAll(async () => {
    await device.launchApp({ newInstance: true, permissions: { notifications: 'YES' } });
  });

  beforeEach(async () => {
    await device.reloadReactNative();
  });

  it('should login successfully with SMS code', async () => {
    // Step 1: Enter credentials
    await element(by.id('email-input')).typeText('user@example.com');
    await element(by.id('password-input')).typeText('SecurePass!123');
    await element(by.id('login-button')).tap();

    // Step 2: Wait for SMS auto‑fill (using a test provider that returns a fixed code)
    await waitFor(element(by.id('code-input'))).toBeVisible().withTimeout(10000);
    await element(by.id('code-input')).typeText('123456'); // fixed code from mock SMS service

    // Step 3: Submit verification
    await element(by.id('verify-button')).tap();

    // Step 4: Assert home screen appears
    await waitFor(element(by.id('home-screen'))).toBeVisible().withTimeout(10000);
  });

  it('should show resend button after simulated SMS delay', async () => {
    // Mock network delay via detox's API (requires a custom native module or jest mock)
    // For demonstration, we assume a mock server endpoint that adds 40s latency
    await device.launchApp({ newInstance: true, delete: true });
    await device.sendToApp({ type: 'MOCK_SMS_DELAY', delayMs: 40000 });

    await element(by.id('email-input')).typeText('user@example.com');
    await element(by.id('password-input')).typeText('SecurePass!123');
    await element(by.id('login-button')).tap();

    // Initially, code input should not be visible
    await expect(element(by.id('code-input'))).not.toBeVisible();
    await waitFor(element(by.id('resend-button'))).toBeVisible().withTimeout(45000);
    await element(by.id('resend-button')).tap();

    // After resend, code appears quickly (mock server resets delay)
    await waitFor(element(by.id('code-input'))).toBeVisible().withTimeout(10000);
    await element(by.id('code-input')).typeText('654321');
    await element(by.id('verify-button')).tap();
    await waitFor(element(by.id('home-screen').toBeVisible().withTimeout(10000('home-screen'))).toBeVisible().withTimeout(10000);
  });
});

Key points

Run the suite with:


detox test -c android.debug

Detox tests typically take 2‑5 minutes per device, suitable for nightly runs or pre‑merge checks on a staging branch.

UI Automation with Appium

If you need cross‑platform coverage (iOS and Android) without writing device‑specific scripts, Appium is a solid choice. Below is a concise Appium JavaScript example that validates the TOTP flow.

First, start the Appium server:


appium

Then the test:


// wdio.conf.js (WebDriverIO + Appium)
exports.config = {
  runner: 'local',
  specs: ['./test/specs/**/*.js'],
  capabilities: [{
    platformName: 'Android',
    'appium:automationName': 'UiAutomator2',
    'appium:app': '/path/to/app-debug.apk',
    'appium:deviceName': 'Pixel_5_API_33',
    'appium:noReset': true,
  }],
  services: [['appium', { command: 'appium' }]],
  framework: 'mocha',
  mochaOpts: { ui: 'bdd', timeout: 60000 },
};

Test file:


// test/specs/totp2fa.spec.js
describe('TOTP 2FA flow', () => {
  it('should accept a valid TOTP code', async () => {
    // Login
    await $('~email-input').setValue('totpuser@example.com');
    await $('~password-input').setValue('SecurePass!123');
    await $('~login-button').click();

    // Wait for TOTP prompt
    await $('~code-input').waitForExist({ timeout: 15000 });

    // Generate a TOTP code using a known secret (for demo, we hardcode)
    const totp = require('speakeasy').totp({
      secret: 'JBSWY3DPEHPK3PXP',
      encoding: 'base32',
    });

    await $('~code-input').setValue(totp);
    await $('~verify-button').click();

    // Expect home screen
    await $('~home-screen').waitForExist({ timeout: 15000 });
  });

  it('should block screenshot on verification screen (Android)', async () => {
    // Trigger login to reach verification screen
    await $('~email-input').setValue('screenshotuser@example.com');
    await $('~password-input').setValue('SecurePass!123');
    await $('~login-button').click();
    await $('~code-input').waitForExist({ timeout: 15000 });

    // Attempt screenshot via adb (requires device connection)
    const result = await driver.execute('mobile: shell', {
      command: 'screencap -p /sdcard/verify.png',
    });
    // On a device with FLAG_SECURE, the file will be zero bytes or contain a blank image
    const screenshot = await driver.execute('mobile: pull', {
      path: '/sdcard/verify.png',
    });
    expect(screenshot.length).toBeLessThan(100); // heuristic: blank or tiny file
  });
});

Appium lets you run the same script against iOS by swapping the platformName and appium:app values. This approach is valuable when you need to verify platform‑specific behaviors like the iOS UIPasteboard restrictions or Android’s FLAG_SECURE.

Using SUSATest for Autonomous Exploration (Mention SUSA)

While scripted tests validate known paths, they rarely simulate the varied behaviors of real users. SUSATest (the autonomous QA platform) can explore your React Native app without any test scripts, exercising the 2FA flow under multiple personas and surfacing issues that scripted tests miss.

To run SUSATest locally:


pip install susatest-agent
susatest explore --apk ./android/app/build/outputs/apk/debug/app-debug.apk \
    --personas curious impatient novice adversarial elderly \
    --flows login signup checkout \
    --output ./susatest-report.json

The agent will:

  1. Install the APK on a connected emulator or device.
  2. Launch the app and begin exploring screens, tapping buttons, entering text, and handling dialogs.
  3. For each persona, it applies a distinct behavior profile (e.g., the impatient persona taps quickly and often uses the “Resend” button; the elderly persona enlarges font size via system settings).
  4. When it encounters a 2FA prompt, it attempts to retrieve a code from a mock SMS service (configured via environment variable) or generates a TOTP using a known secret.
  5. It records any crashes, ANRs, accessibility violations (WCAG 2.1 AA), security warnings (e.g., credentials in logs), and UX friction such as dead buttons or confusing error messages.
  6. After the run, you receive a JSON report with PASS/FAIL verdicts for each tracked flow and a list of discovered defects.

Why this matters for 2FA

Because SUSATest does not rely on pre‑written test cases, it can discover edge cases like a race condition where the verification screen appears *before* the SMS listener is registered, causing the code to be missed. Such timing issues are notoriously hard to catch with deterministic scripts but emerge naturally when the explorer varies the pacing of interactions.

You can integrate SUSATest into your CI pipeline as a nightly job:


susatest explore --apk ./app-release.apk \
    --personas all \
    --flows login \
    --ci \
    --token $SUSA_API_TOKEN \
    --output ./susatest-ci.json

The --ci flag makes the process exit with a non‑zero status if any critical defect (crash, ANR, or security leak) is found, gating merges to main.

> Note: SUSATest is mentioned here only to illustrate how autonomous, persona‑driven testing complements manual and scripted approaches. The core guidance in this guide remains applicable whether or not you use SUSATest.

Accessibility and Security Considerations for 2FA

Beyond functional correctness, 2FA implementations must satisfy accessibility guidelines and resist common attacks. Below are concise checklists you can embed in your Definition of Done.

Accessibility Checklist (WCAG 2.1 AA)

ItemHow to Verify
Labels for all inputsaccessibilityLabel on code input matches visible placeholder (“Enter 6‑digit code”).
Error messages announcedTrigger an invalid code; ensure TalkBack/VoiceOver reads the alert.
Sufficient touch target sizeButtons ≥ 48 dp; test with Android’s “Developer options → Show touch targets”.
Responsive to font scalingIncrease system font to 200 %; verify no clipping or horizontal scrolling.
No reliance on color aloneError state uses both red text and an icon (⚠️).
Logical focus orderTab/navigation moves from email → password → “Login” → code input → “Verify”.
Accessible fallbackIf SMS fails, the alternative authenticator button is reachable without scrolling.

Security Checklist

ItemHow to Verify
No code leakage in logsRun adb logcat (Android) or Xcode console while entering a code; assert absence of the token.
Secure transmissionConfirm API calls use HTTPS with certificate pinning (use react-native-ssl-pinning or equivalent).
Rate limiting on client & serverAfter five failed attempts, client disables button; server returns HTTP 429.
Replay attack resistanceServer validates TOTP within a 30‑second window and increments a used‑token cache.
Device binding (optional)For high‑value actions, require device‑specific key (e.g., Secure Enclave) in addition to 2FA.
No storage of plaintext secretsConfirm that TOTP seeds are kept in encrypted Keychain/Keystore, not in AsyncStorage.
Protection against screen captureOn Android, window adds FLAG_SECURE; on iOS, prevent UIApplicationUserDidTakeScreenshotNotification from capturing the view (obscure view).

Automated tests can verify many of these items: unit tests for pinning, integration tests for rate‑limit UI, and Detox/Appium scripts for checking FLAG_SECURE via adb shell dumpsys window windows | grep mCurrentFocus (look for secure=true). Accessibility checks remain largely manual, though tools like axe-core-react-native can flag missing labels in CI.

Edge Cases That Only Appear in Production

Even the most thorough test matrix can miss issues that manifest only under real‑world conditions. Below are several production‑only edge cases we have observed, along with mitigation strategies.

Edge CaseWhy It Happens in ProductionMitigation
Carrier‑specific SMS filteringSome carriers block messages from short codes that resemble promotional spam.Use alphanumeric sender IDs where permitted; provide a fallback to email or authenticator app.
Push notification silent deliveryUsers may have disabled notifications or enabled battery‑optimization that delays push.Detect missing push after a timeout and prompt the user to check notification settings.
Time zone changes while travelingA user changes time zone; device clock drifts relative to server, causing TOTP failures.Use network time (NTP) to synchronize or allow a larger verification window (±2 min) with server‑side replay cache.
Multiple apps sharing same keychain entryOn iOS, if two apps from the same vendor share a keychain group, the 2FA secret may be overwritten.Scope secrets to the app‑specific access group; use KeychainAccess with service identifier unique to the app.
Low‑memory killer terminating background SMS receiverAndroid may kill a service that listens for SMS Retriever API callbacks, causing missed codes.Use a foreground service with a persistent notification for the SMS listener, or rely on the user‑initiated manual entry path as primary.
User copies code from notification shade but paste failsSome keyboards block paste into secure fields; users then retype incorrectly.Provide a “Use code from notification” button that programmatically fills the field via the SMS Retriever API.
Network switch mid‑flow (Wi‑Fi to cellular)A captive portal redirects HTTP requests, breaking the verification call.Listen to NetInfo changes and automatically retry the verification request after connectivity is restored.
Biometric lockout after too many failed attemptsDevice locks fingerprint after 5 failures; user then cannot use biometric fallback.Offer a clear “Use PIN/password” alternative after biometric error.
App update while 2FA screen is visibleA background install replaces the native module handling SMS, causing a crash.Detect AppState changes and, if an update is pending, gracefully navigate away from the 2FA screen before the install proceeds.

Mitigating these issues often requires combining client‑side resilience (retries, fallback paths) with server‑side flexibility (wider time windows, alternative delivery channels). Document any such accommodations in your run‑book so that support teams know how to assist affected users.

Checklist for 2FA Testing in React Native

Use this concise list before each release candidate. Tick each item; any unchecked box warrants investigation.

Test Your App Autonomously

Upload your APK or URL. SUSA explores like 10 real users — finds bugs, accessibility violations, and security issues. No scripts.

Try SUSA Free