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

How to Test Two-Factor Authentication on Flutter (Complete Guide) starts with understanding why 2FA is critical for Flutter apps and what typically goes wrong in production. Two‑factor authentication

February 02, 2026 · 16 min read · How-To Guides

How to Test Two-Factor Authentication on Flutter (Complete Guide) starts with understanding why 2FA is critical for Flutter apps and what typically goes wrong in production. Two‑factor authentication adds a second verification step—usually a time‑based one‑time password (TOTP), SMS code, push notification, or biometric factor—on top of a password. In Flutter applications the authentication flow often lives across multiple widgets, platform channels, and third‑party SDKs (Firebase Auth, Auth0, custom OAuth). A missed edge case can let an attacker bypass the second factor, lock out legitimate users, or expose secrets in logs. This guide walks you through a complete test strategy: a detailed test matrix, manual steps, automated unit/widget/integration tests, provider‑specific checks, accessibility and security considerations, and how autonomous, persona‑driven exploration surfaces bugs that scripted tests miss.

---

How to Test Two-Factor Authentication on Flutter (Complete Guide): Why 2FA Matters and Common Failure Modes

Why 2FA Is Non‑Optional for Flutter Apps

Flutter apps frequently handle sensitive data—personal health records, financial transactions, or enterprise credentials. Even if the same threat model that applies to native Android/iOS apps applies here: credential stuffing, phishing, SIM‑swap, and man‑in‑the‑middle attacks. A correctly implemented 2FA flow reduces the success rate of credential‑based attacks from ~80 % to <5 % according to recent breach analyses.

Typical Production Failures

Failure CategorySymptom in Flutter UIRoot Cause (Flutter‑specific)
State loss during code entryAfter entering the SMS code, the app returns to the login screen without errorThe AuthBloc disposes before the asynchronous SMS verification callback resolves; the UI rebuilds with stale state
Incorrect handling of expired TOTPUser sees “Invalid code” despite correct entry, then gets locked out after three attemptsThe TOTP validation uses DateTime.now() from the UI thread, which can drift if the isolate is paused during a heavy animation
Missing error propagation from platform channelNo toast or dialog appears when the native SMS retriever times outThe Flutter side only listens for a success channel; failure channel is never subscribed to
Accessibility label missingTalkBack reads “button” instead of “Enter verification code”The TextFormField lacks a labelText or semanticLabel, causing screen‑reader users to miss the field
Hard‑coded backup codes in assetsBackup codes visible when inspecting the APKDevelopers placed static backup codes in assets/backup_codes.json for convenience, exposing them to anyone who unpacks the app
Rate‑limit bypass via hot reloadDuring dev, rapid hot reload lets a tester submit 20 codes in a second without being throttledThe rate‑limit logic lives in a Singleton that is re‑initialized on each hot reload, resetting the counter

Each of these issues can slip through unit tests because they involve timing, platform interactions, or UI state that only manifests in a full‑stack run.

---

How to Test Two-Factor Authentication on Flutter (Complete Guide): Core Concepts and Flutter‑Specific Considerations

Authentication Architecture in Flutter

Most Flutter 2FA implementations follow one of three patterns:

  1. Bloc/Cubit with Repository – UI widgets dispatch events (LoginStarted, OtpSubmitted) to a bloc that calls a repository exposing verifyPhoneNumber, verifyTotp, etc.
  2. Provider/ChangeNotifier – A AuthProvider holds isLoading, errorMessage, and user state; widgets consume via Consumer.
  3. Direct SDK Calls – Widgets call Firebase Auth methods directly (FirebaseAuth.instance.signInWithCredential(phoneAuthCredential)) and manage state with setState.

Regardless of pattern, the testable surface includes:

Testability Hooks to Build In

When these hooks exist, unit and widget tests can drive the 2FA flow without needing a real SMS gateway or biometric hardware.

---

How to Test Two-Factor Authentication on Flutter (Complete Guide): Test Matrix (Happy Path, Error Paths, Edge Cases, Accessibility, Security)

Below is a comprehensive matrix you can copy into a test‑management tool (e.g., TestRail, Zephyr) or a simple spreadsheet. Each row represents a distinct test scenario; columns indicate the test type (manual, automated unit/widget, integration, autonomous) and the expected verdict.

IDCategoryDescriptionTest Type(s)Expected Result
1Happy Path – Phone + SMSUser enters valid phone, receives SMS, submits correct 6‑digit code, gains accessManual, Widget, IntegrationLogin succeeds, navigation to home screen
2Happy Path – Email + TOTPUser enters email, receives authenticator‑app secret, scans QR, enters correct TOTP, login succeedsManual, Widget, IntegrationSame as #1
3Happy Path – Push NotificationUser approves login via push (simulated via mock server)Manual, IntegrationLogin succeeds
4Error – Wrong OTPUser submits incorrect OTP three timesManual, WidgetShows “Invalid code”, remains on OTP screen, error count increments
5Error – Expired OTPUser waits >30 s (TOTP) or >60 s (SMS) then submits codeManual, WidgetShows “Code expired”, allows resend
6Error – Network FailureSimulate loss of connectivity after OTP entryWidget (mock repository)Shows “Network error”, offers retry, does not crash
7Error – Backend 500Mock server returns 500 after OTP verificationWidget, IntegrationShows generic error, logs details, no sensitive data leaked
8Edge – Empty Phone FieldUser taps submit with blank phone numberWidgetShows validation error “Phone number required”
9Edge – Non‑numeric PhoneUser enters letters in phone fieldWidgetShows “Please enter digits only”
10Edge – Leading Zero TruncationPhone number starts with 0 (e.g., 01234…) and is stripped by formattingWidgetPreserves leading zero after input mask
11Edge – Max Length PasteUser pastes a 20‑character string into OTP fieldWidgetField accepts only first 6 characters, ignores rest
12Edge – Biometric FallbackDevice lacks fingerprint; app falls back to OTP after biometric prompt times outManual, IntegrationShows OTP screen after timeout
13Accessibility – TalkBack LabelsAll input fields and buttons have meaningful semanticLabelManual (TalkBack), Widget (semantics test)TalkBack reads “Enter phone number”, “Send code button”, etc.
14Accessibility – ContrastOTP field background vs. text meets WCAG AA (≥4.5:1)Manual (contrast checker), Automated (flutter_lints)Contrast ratio ≥4.5
15Accessibility – Touch TargetButtons ≥48 dpManual (UI inspector), Widget (size test)Touch target passes
16Security – Rate LimitingAfter 5 failed OTP attempts, further attempts are blocked for 5 minManual, Integration (mock backend)Shows “Too many attempts”, timer counts down
17Security – Code ReuseSame OTP submitted twice is rejected on second attemptWidgetSecond submission shows “Code already used”
18Security – Secret StorageOTP seed or SMS token never written to logs or plain‑text filesManual (logcat inspection), Automated (test logger)No occurrence of seed in logs
19Security – Memory ScrubbingAfter successful login, OTP held in memory is clearedManual (memory profiler), Widget (unit test with fake OTP)OTP string becomes null or overwritten
20Edge – Locale Change Mid‑FlowUser switches device language after OTP screen appearsManual translation updates without losing entered OTP
21Edge – Dark ModeUI remains legible and contrast compliant in dark themeManual, WidgetAll text meets contrast, icons adapt
22Edge – Font ScaleSystem font size set to largest (200 %)Manual, WidgetNo overflow, scrollable if needed
23Edge – Interruption (Call)Incoming voice call arrives while OTP screen is visible; after call ends, app resumes correctlyManualOTP field retains entered digits, no crash
24Edge – Battery SaverDevice in extreme battery‑saver mode; background SMS retriever may be delayedManualApp shows resend button after appropriate timeout, does not false‑positive “expired”
25Autonomous ExplorationSUSA agent runs with curious, impatient, novice, adversarial, elderly, accessibility, power‑user personasAutonomous (SUSA)Discovers any of the above failures that scripted tests miss (e.g., hidden dead‑end after pressing back twice)

How to use the matrix

---

How to Test Two-Factor Authentication on Flutter (Complete Guide): Manual Testing Step‑by‑Step

Preparing the Test Environment

  1. Device selection – Use at least one physical Android device (API 30+) and one iOS device (iOS 15+) to catch platform‑specific quirks.
  2. Clear app data – Before each test run, go to Settings → Apps → YourApp → Storage → Clear Cache and Data. This ensures a clean state (no leftover OTP secrets).
  3. Enable logging – Connect via adb logcat (Android) or Console.app (iOS) and filter for your app tag.
  4. Set up mock SMS gateway – If testing SMS‑based 2FA, use a service like Twilio’s test credentials or a local mock server that returns a predefined code on request. Point your backend to this mock via environment variables or a .env file.
  5. Configure accessibility tools – Turn on TalkBack (Android) or VoiceOver (iOS) and a contrast analyzer (e.g., Android’s Accessibility Scanner).

Test Script for Happy Path (SMS)

StepActionExpected Observation
1Launch app, navigate to Login screenSee “Enter phone number” field and “Continue” button
2Enter a valid test phone number (e.g., +15551234567)Keyboard shows numeric input; “Continue” becomes enabled
3Tap ContinueProgress spinner appears; after 2‑3 s a toast says “We’ve sent a code”
4Switch to SMS mock viewer, retrieve the 6‑digit code (e.g., 123456)
5Return to app, enter the code in the OTP field (six separate boxes or single field)Each box fills; after sixth digit, “Verify” button enables
6Tap VerifySpinner, then navigation to Home screen; no error toast appears
7Verify sessionCheck that SecureStorage contains an auth token, and that the user profile shows the logged‑in email/ID
8Log out and repeat steps 1‑7 with a different phone numberSame success flow, confirming no state leakage

Test Script for Error Path (Wrong OTP)

StepActionExpected Observation
1‑3Same as happy path up to receiving code
4Enter an incorrect code (e.g., 654321)After sixth digit, “Verify” button stays enabled (or becomes enabled depending on design)
5Tap VerifyError toast: “Invalid code. Please try again.” OTP field clears or retains first five digits per spec
6Repeat steps 4‑5 two more timesAfter third failure, either show “Too many attempts” or lock the OTP field for a cooldown period
7Wait out cooldown (if applicable)OTP field becomes editable again, timer shows remaining time
8Enter correct codeLogin proceeds as in happy path

Accessibility Checks (Manual)

Security‑Focused Manual Checks

---

How to Test Two-Factor Authentication on Flutter (Complete Guide): Automated Unit and Widget Tests

Setting Up the Test Environment

Add the following to dev_dependencies in pubspec.yaml:


dev_dependencies:
  flutter_test:
    sdk: flutter
  mockito: ^5.4.0   # or mocktail if you prefer
  flutter_lints: ^3.0.0

Create a folder structure:


test/
  ├─ auth/
  │    ├─ login_page_test.dart
  │    ├─ otp_page_test.dart
  │    └─ auth_bloc_test.dart
  └─ fakes/
       ├─ fake_auth_repository.dart
       └─ fake_sms_service.dart

Example: Widget Test for OTP Input


import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:your_app/auth/otp_page.dart';
import 'package:your_app/auth/auth_repository.dart';

class MockAuthRepository extends Mock implements AuthRepository {}

void main() {
  late MockAuthRepository repo;

  setUp(() {
    repo = MockAuthRepository();
    when(() -> repo.verifyOtp(any())).thenAnswer((_) async => true);
  });

  testWidgets('OTP page shows error on wrong code', (tester) async {
    await tester.pumpWidget(
      MaterialApp(
        home: Provider.value(
          value: repo,
          child: const OtpPage(),
        ),
      ),
    );

    // Enter wrong OTP
    await tester.enterText(find.byKey(const Key('otpField')), '111111');
    await tester.tap(find.text('Verify'));
    await tester.pump(const Duration(seconds: 1));

    expect(find.textContaining('Invalid code'), findsOneWidget);
    verify(() -> repo.verifyOtp('111111')).called(1);
  });

  testWidgets('OTP page navigates home on correct code', (tester) async {
    await tester.pumpWidget(
      MaterialApp(
        home: Provider.value(
          value: repo,
          child: const OtpPage(),
        ),
      ),
    );

    await tester.enterText(find.byKey(const Key('otpField')), '123456');
    await tester.tap(find.text('Verify'));
    await tester.pumpAndSettle();

    expect(find.byType(HomePage), findsOneWidget);
    verify(() -> repo.verifyOtp('123456')).called(1);
  });
}

*Key points*:

Unit Test for Bloc Logic

If you use flutter_bloc, test the state transitions:


import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:your_app/auth/login_bloc.dart';
import 'package:your_app/auth/auth_repository.dart';

class MockAuthRepository extends Mock implements AuthRepository {}

void main() {
  late MockAuthRepository repo;
  late LoginBloc bloc;

  setUp(() {
    repo = MockAuthRepository();
    bloc = LoginBloc(repository: repo);
  });

  tearDown(() => bloc.close());

  test('emits [loading, success] when otp is valid', () async {
    when(() -> repo.verifyOtp(any())).thenAnswer((_) async => true);

    final expected = [
      LoginState.loading(),
      LoginState.success(),
    ];
    expectLater(
      bloc.stream,
      emitsInOrder(expected),
    );

    bloc.add(const OtpSubmitted(otp: '123456'));
  });

  test('emits [loading, error] when otp is invalid', () async {
    when(() -> repo.verifyOtp(any())).thenAnswer((_) async => false);

    final expected = [
      LoginState.loading(),
      LoginState.error(message: 'Invalid code'),
    ];
    expectLater(
      bloc.stream,
      emitsInOrder(expected),
    );

    bloc.add(const OtpSubmitted(otp: '654321'));
  });
}

These tests run in milliseconds on CI and give you fast feedback on business logic.

---

How to Test Two-Factor Authentication on Flutter (Complete Guide): Integration Tests with flutter_test and integration_test

Why Integration Tests?

Unit/widget tests verify isolated pieces; integration tests confirm that the whole Flutter‑to‑backend pipeline works, including platform channels, native SMS retriever, and secure storage. They are slower but essential for catching state‑loss bugs like the “disposes before callback resolves” scenario described earlier.

Adding integration_test


dev_dependencies:
  integration_test:
    sdk: flutter

Create integration_test/app_test.dart:


import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:your_app/main.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('End-to-End 2FA flow', () {
    testWidgets('login with SMS OTP succeeds', (tester) async {
      app.main();
      await tester.pumpAndSettle();

      // 1. Enter phone
      await tester.enterText(find.byKey(const Key('phoneField')), '+15551234567');
      await tester.tap(find.text('Continue'));
      await tester.pumpAndSettle();

      // 2. Wait for simulated SMS arrival (mock server returns after 2s)
      await tester.pump(const Duration(seconds: 3));

      // 3. Enter OTP from mock
      await tester.enterText(find.byKey(const Key('otpField')), '987654');
      await tester.tap(find.text('Verify'));

      // 4. Assert home screen reached
      expect(find.byType(HomePage), findsOneWidget);
    });

    testWidgets('shows error after three wrong OTP attempts', (tester) async {
      app.main();
      await tester.pumpAndSettle();

      await tester.enterText(find.byKey(const Key('phoneField')), '+15551234567');
      await tester.tap(find.text('Continue'));
      await tester.pumpAndSettle();

      // Simulate three failures
      for (int i = 0; i < 3; i++) {
        await tester.enterText(find.byKey(const Key('otpField')), '000000');
        await tester.tap(find.text('Verify'));
        await tester.pumpAndSettle();
        expect(find.textContaining('Invalid code'), findsOneWidget);
      }

      // After third, expect lockout message
      expect(find.textContaining('Too many attempts'), findsOneWidget);
    });
  });
}

Run with:


flutter drive --target=integration_test/app_test.dart -d emulator-5554

Mocking Backend Services

For reliable CI, spin up a lightweight mock server (e.g., using mockeray or a simple Express app) that exposes endpoints:

Set the app’s base URL via environment variable or --dart-define=API_BASE_URL=http://10.0.2.2:3000 (the Android emulator’s alias for host localhost).

Testing Platform Channels

If you rely on a plugin like firebase_auth, you can use the firebase_auth_mocks package to avoid real network calls while still exercising the plugin’s MethodChannel logic:


import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('FirebaseAuth mock verifies OTP', (tester) async {
    final mockAuth = MockFirebaseAuth(
      // pre‑setup a phone auth credential that will succeed
      mockUser: MockUser(isAnonymous: false, uid: 'test-uid'),
    );

    await tester.pumpWidget(
      Provider<FirebaseAuth>(
        create: (_) => mockAuth,
        child: const MaterialApp(
          home: OtpPage(),
        ),
      ),
    );

    await tester.enterText(find.byKey(const Key('otpField')), '111111');
    await tester.tap(find.text('Verify'));
    await tester.pumpAndSettle();

    // Expect navigation to home or a success snackbar
    expect(find.byType(HomePage), findsOneWidget);
  });
}

---

How to Test Two-Factor Authentication on Flutter (Complete Guide): Using Firebase Auth and Other Providers for 2FA

Firebase Auth Phone Flow

Firebase Auth abstracts the SMS retrieval via verifyPhoneNumber. The Flutter side receives three callbacks: codeSent, codeAutoRetrievalTimeout, and verificationCompleted. To test:

  1. Mock FirebaseAuth.instance using mockito or firebase_auth_mocks.
  2. Simulate codeSent by calling the callback with a fake verification ID.
  3. Provide a fabricated SMS code via PhoneAuthCredential.

Example snippet:


final FirebaseAuth auth = MockFirebaseAuth();
when(() => auth.verifyPhoneNumber(
      phoneNumber: any(named: 'phoneNumber'),
      verificationCompleted: any(named: 'verificationCompleted'),
      codeSent: any(named: 'codeSent'),
      codeAutoRetrievalTimeout: any(named: 'codeAutoRetrievalTimeout'),
      onFailed: any(named: 'onFailed'),
    )).thenAnswer((_) async => {});

final completer = Completer<void>();
when(() => auth.verifyPhoneNumber(
      phoneNumber: '+15551234567',
      verificationCompleted: any,
      codeSent: captureAny,
      codeAutoRetrievalTimeout: any,
      onFailed: any,
    )).thenAnswer((invocation) {
  final codeSent = invocation.positionalArgument<Function(String, int?)>('codeSent');
  codeSent('fakeVerificationId', 0); // simulate code sent
  return completer.future;
});

Then in the widget test, trigger the phone number submission, wait for the codeSent callback to fire, call signInWithCredential(PhoneAuthCredential(verificationId: 'fakeVerificationId', smsCode: '123456')), and assert navigation.

Auth0 and Custom OAuth Providers

When using Auth0’s MFA, the flow typically involves:

  1. Username/password login → receives a mfa_required error with a mfa_token.
  2. Polling /mfa/challenge with the token to push a notification to the Auth0 Guardian app or to show a TOTP prompt.
  3. Submitting the OTP via /mfa/totp endpoint.

To test:

Testing with Magic Link or Email‑Based OTP

Some providers send a one‑time link to email. In tests:

---

How to Test Two-Factor Authentication on Flutter (Complete Guide): Accessibility and Localization Checks for 2FA Screens

Automated Accessibility Testing

Add the accessibility_test package to dev_dependencies and write a test that uses the SemanticsHandler to verify labels and traits:


import 'package:flutter_test/flutter_test.dart';
import 'package:accessibility_test/accessibility_test.dart';
import 'package:your_app/auth/otp_page.dart';

void main() {
  testWidgets('OTP page has proper semantics', (tester) async {
    await tester.pumpWidget(const MaterialApp(
      home: OtpPage(),
    ));

    final semantics = await tester.getSemantics();
    final phoneField = semantics.firstWhere(
      (node) => node.label == 'Phone number',
      orElse: () => throw StateError('Phone number field missing semantics'),
    );
    expect(phoneField.traits, contains(SemanticTrait.textField));

    final verifyButton = semantics.firstWhere(
      (node) => node.label == 'Verify code',
      orElse: () => throw StateError('Verify button missing semantics'),
    );
    expect(verifyButton.traits, contains(SemanticTrait.button));
  });
}

Run this test on every PR to catch regressions early.

Localization (l10n) Verification

If you use Flutter’s intl package with ARB files, ensure that all strings shown during 2FA have translations. A simple test:


import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:your_app/l10n/l10n.dart';
import 'package:your_app/auth/otp_page.dart';

void main() {
  testWidgets('OTP page displays correct Spanish strings', (tester) async {
    await tester.pumpWidget(
      MaterialApp(
        localizationsDelegates: L10n.localizationsDelegates,
        supportedLocales: L10n.supportedLocales,
        locale: const Locale('es'),
        home: const OtpPage(),
      ),
    );

    expect(find.textContaining('Ingrese su número de teléfono'), findsOneWidget);
    expect(find.textContaining('Verificar código'), findsOneWidget);
  });
}

Add similar tests for each supported language (e.g., fr, zh, ar).

Touch Target and Contrast Automation

Use the flutter_lints rule avoid_print is not relevant, but you can add custom lint rules via custom_lint to enforce:

Create a lint_rules.yaml and run flutter analyze --options lint_rules.yaml.

---

How to Test Two-Factor Authentication on Flutter (Complete Guide): Security and Privacy Testing (Rate Limiting, Phishing Resistance, etc.)

Rate‑Limit Verification

Backend – Ensure the authentication endpoint enforces per‑IP or per‑account limits (e.g., 5 attempts per 5 min).

Client‑Side – The app should

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