Disclosure: I work with HeroSMS, the virtual number provider used in the examples below. Written with AI tools; code and endpoints checked against the HeroSMS API reference.

If you've shipped Firebase phone auth in a Flutter app, this story may sound familiar. On your machine everything is green: you add a test number in the Firebase console, type 123456, sign in, merge. After the release, the reports start: "I never got the code." Sometimes it's a whole country, sometimes a few users. The logs are empty, because verifyPhoneNumber returns Future<void> and the real error sits in a verificationFailed callback nobody logs.

Firebase test phone numbers (the docs call them fictional numbers, and so will I) send no SMS. They prove your app calls the API correctly, not that a real SMS reaches a real phone in each country. Real-SMS testing has its own problems: emulators can't use real numbers, CI runners have no SIM, and every run sends a billable SMS.

This post splits Firebase phone auth testing into two layers: fictional numbers on every PR, and a scheduled smoke test that sends a real SMS to a number rented in each market. Stack: Flutter, firebase_auth, integration_test, GitHub Actions.

Set up the Firebase project for phone auth

Many "OTP not sent" bugs come from project config, not Dart code:

  1. Link Cloud Billing (Blaze). Since September 2024, a project needs Cloud Billing to use the SMS service.
  2. Enable the Phone provider under Security > Authentication > Sign-in method.
  3. Set an SMS region policy under Security > Authentication > Settings. For new projects, the default policy allows no regions. Use an allowlist or a denylist; only one can be active.
  4. Android: add SHA-1 and SHA-256 fingerprints (SHA-256 is for Play Integrity). Without Google Play services, or when the app isn't distributed through the Play Store, the SDK falls back to reCAPTCHA, which needs SHA-1 and an API key that is unrestricted or allowlisted for PROJECT_ID.firebaseapp.com.
  5. iOS: enable Push Notifications and Background Modes (Background fetch, Remote notifications), upload an APNs key, and add your Encoded App ID as a URL scheme for the reCAPTCHA fallback.
  6. Web: add your domain to Authorized domains. Per the docs, localhost is not allowed as a hosted domain for phone auth.

Packages: firebase_auth 7.x (7.0.1 as I write; 7.0 requires Android minSdk 24) and firebase_core 5.x; integration_test, flutter_test and http as dev dependencies.

verifyPhoneNumber and its four callbacks

verifyPhoneNumber returns Future<void>. Results arrive through four callbacks:

  • verificationCompleted: Android only. The SMS was read automatically, or the number was verified instantly without a code.
  • verificationFailed: bad number format, blocked region, quota exceeded.
  • codeSent(verificationId, resendToken): the SMS is on its way. On iOS, resendToken is always null.
  • codeAutoRetrievalTimeout: Android stopped waiting for auto-retrieval (timeout defaults to 30 seconds).

Numbers need a + and a country code. Web uses signInWithPhoneNumber, which returns a ConfirmationResult and renders an invisible reCAPTCHA by default. One service wraps both paths, so the UI and the tests share it:

// lib/phone_auth_service.dart import 'dart:async'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/foundation.dart' show debugPrint, kIsWeb; class PhoneAuthService { PhoneAuthService([FirebaseAuth? auth]) : _auth = auth ?? FirebaseAuth.instance; final FirebaseAuth _auth; String? _verificationId; int? _resendToken; // Android only; always null on iOS ConfirmationResult? _webResult; // web only /// UserCredential: Android finished verification on its own (instant or auto-read SMS). /// null: the SMS was sent; wait for the user to type the code, then call confirm(). /// Error: the FirebaseAuthException from verificationFailed. Future<UserCredential?> start(String phoneE164, {bool resend = false}) async { if (kIsWeb) { // Without a RecaptchaVerifier, the SDK creates an invisible reCAPTCHA _webResult = await _auth.signInWithPhoneNumber(phoneE164); return null; } final done = Completer<UserCredential?>(); await _auth.verifyPhoneNumber( phoneNumber: phoneE164, // E.164 like +84...; the country code is required timeout: const Duration(seconds: 60), // default 30 seconds, max 2 minutes forceResendingToken: resend ? _resendToken : null, verificationCompleted: (PhoneAuthCredential credential) async { // Android only. It can arrive AFTER codeSent and sign the user in silently, // so the UI should listen to authStateChanges() instead of only awaiting this Future. try { final uc = await _auth.signInWithCredential(credential); if (!done.isCompleted) done.complete(uc); } on FirebaseAuthException catch (e) { if (!done.isCompleted) done.completeError(e); } }, verificationFailed: (FirebaseAuthException e) { debugPrint('verificationFailed: ${e.code}'); // the real error lives here, don't swallow it if (!done.isCompleted) done.completeError(e); }, codeSent: (String verificationId, int? resendToken) { _verificationId = verificationId; _resendToken = resendToken; if (!done.isCompleted) done.complete(null); }, codeAutoRetrievalTimeout: (String verificationId) { _verificationId = verificationId; // Android only }, ); return done.future; } Future<UserCredential> confirm(String smsCode) { if (kIsWeb) return _webResult!.confirm(smsCode); final credential = PhoneAuthProvider.credential( verificationId: _verificationId!, smsCode: smsCode, ); return _auth.signInWithCredential(credential); } } /// The docs only show 'invalid-phone-number'; the other code is a common example, /// so always keep the default branch. String phoneAuthMessage(FirebaseAuthException e) => switch (e.code) { 'invalid-phone-number' => 'Invalid phone number. Include the country code.', 'too-many-requests' => 'Too many attempts. Please wait a moment.', _ => 'Could not send the code (${e.code}). Please try again later.', }; 

Two easy-to-miss details:

  • Calling verifyPhoneNumber again for the same number before the previous request times out sends no new SMS, so "Resend code" should call start(phone, resend: true) to pass forceResendingToken (Android only).
  • Google stores the phone numbers you collect to fight abuse, so get consent and tell users an SMS is coming.

Firebase test phone numbers: test the OTP flow without SMS

A fictional number is a phone number paired with a fixed 6-digit code, added under Security > Authentication > Sign-in method > Phone > Phone numbers for testing (up to 10). No SMS is sent, no quota is used, and repeated runs aren't throttled. The Flutter phone auth docs say phone sign-in only works on real devices and the web; on emulators, use fictional numbers.

Pick fake but well-formed numbers, such as the US 555 range (+1 650-555-3434 in the web guide). A fictional user's ID token has the same signature as a real one, so use hard-to-guess numbers and codes, rotate them, and keep them out of production builds.

setSettings(appVerificationDisabledForTesting: true) turns off app verification (Play Integrity, APNs, reCAPTCHA), so tests run on emulators and simulators. It works only with fictional numbers: with the flag on, a real number fails to sign in. Call it before verifyPhoneNumber, from test code only.

// integration_test/phone_auth_test.dart // Run: flutter test integration_test/phone_auth_test.dart -d emulator-5554 \ // --dart-define-from-file=test_secrets.json (this file is in .gitignore) import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:my_app/firebase_options.dart'; // generated by `flutterfire configure` import 'package:my_app/phone_auth_service.dart'; // Must be const, or String.fromEnvironment isn't guaranteed to read the value const testPhone = String.fromEnvironment('TEST_PHONE'); const testCode = String.fromEnvironment('TEST_SMS_CODE'); void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); setUpAll(() async { await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); // ONLY affects numbers listed under "Phone numbers for testing" await FirebaseAuth.instance.setSettings(appVerificationDisabledForTesting: true); }); tearDown(() => FirebaseAuth.instance.signOut()); testWidgets('signs in with a Firebase fictional number', (tester) async { expect(testPhone, isNotEmpty, reason: 'TEST_PHONE is missing from --dart-define'); expect(testCode, isNotEmpty, reason: 'TEST_SMS_CODE is missing from --dart-define'); final service = PhoneAuthService(); // Bounded, so a CI job doesn't hang if no callback ever fires final auto = await service.start(testPhone).timeout(const Duration(seconds: 90)); final uc = auto ?? await service.confirm(testCode); expect(uc.user, isNotNull); expect(FirebaseAuth.instance.currentUser?.uid, uc.user!.uid); }); } 

To test auto-retrieval, also call setSettings(phoneNumber: testPhone, smsCode: testCode) before start(), and verificationCompleted fires right away (Android only). On web, integration tests need flutter drive and the localhost rule gets in the way, so this post sticks to Android and iOS.

Why Firebase OTP still fails in production

The test above sends no SMS and skips app verification, so it can't tell you whether a real SMS reaches users in each country. Common causes, from the Firebase Auth FAQ and the phone auth guides (error codes are real-world examples, not an official list):

Symptom Cause Fix
SMS service can't be enabled No Cloud Billing Upgrade to Blaze
New project, real numbers get nothing Default region policy allows no regions Add your markets to the allowlist
operation-not-allowed, "SMS unable to be sent until this region enabled by the app developer" Region blocked by the policy (from community reports, not the docs) Check the allowlist or denylist
too-many-requests Too many requests from one device, or SMS quota used up Wait, then retry; don't hammer
App verification errors Missing SHA, restricted API key, missing APNs key or URL scheme See the setup list
codeSent fires but no SMS arrives Carrier didn't deliver; number was ported to another carrier Try another number

The FAQ says plainly that not every carrier delivers verification SMS reliably. Ported numbers currently get no SMS at all, with no workaround, so ask users who never got a code whether they switched carriers. Firebase also limits how many SMS go to one number (the figure isn't published), so don't point every test run at the same number.

The most direct way to catch these before your users do is a real SMS to a number in each market ahead of release.

Test Firebase SMS OTP with a real number per market

Rent a temporary number in the country you're checking, let Firebase send a real SMS to it, read the code over an API, and sign in. I use HeroSMS as the example; with another provider, you only rewrite SmsProvider.

The client speaks the handler_api protocol many virtual-number services share: GET requests to https://hero-sms.com/stubs/handler_api.php with api_key and action in the query string. HeroSMS also has a REST API at https://hero-sms.com/api/v1 with an Authorization: ApiKey <token> header, which keeps the key out of URLs; the lifecycle is the same, so switching only touches SmsProvider. Three actions:

  • getNumberV2 rents a number: JSON with activationId, phoneNumber (no +), activationCost, activationEndTime.
  • getStatus returns STATUS_WAIT_CODE while waiting, STATUS_OK:<code> once the code is in, STATUS_CANCEL if canceled.
  • setStatus with 6 completes the activation; 8 cancels it and refunds the price.

Errors are JSON like {"title": "BAD_KEY", ...} with an HTTP 4xx, so check the status code first. NO_NUMBERS is a plain string with HTTP 200. setStatus can return 409, for example EARLY_CANCEL_DENIED (no cancel in the first 2 minutes) or OTP_RECEIVED (the SMS already arrived).

The catch: an integration test runs on the phone, not on the CI machine, so it can't read the runner's environment. The URL and key come in through --dart-define.

// integration_test/support/sms_provider.dart import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; // The test runs on the phone and can't see the runner's env: values come in via --dart-define const _apiUrl = String.fromEnvironment('SMS_API_URL'); // the .../stubs/handler_api.php endpoint const _apiKey = String.fromEnvironment('SMS_API_KEY'); // CI secret, never committed class SmsProviderException implements Exception { SmsProviderException(this.code, [this.httpStatus]); final String code; // BAD_KEY, NO_NUMBERS, NO_BALANCE, EARLY_CANCEL_DENIED... final int? httpStatus; @override String toString() => 'SmsProviderException($code, HTTP ${httpStatus ?? 200})'; } class Activation { Activation(this.id, this.phone, this.cost) : rentedAt = DateTime.now(); final String id; final String phone; // +E.164 final String cost; // activationCost: add these up to see what each run costs final DateTime rentedAt; } class SmsProvider { final _http = http.Client(); Future<Object?> _call(Map<String, String> params) async { if (_apiUrl.isEmpty || _apiKey.isEmpty) { throw StateError('SMS_API_URL or SMS_API_KEY is missing'); // don't print the values } final base = Uri.parse(_apiUrl); final uri = base.replace( queryParameters: {...base.queryParameters, 'api_key': _apiKey, ...params}); final http.Response res; try { res = await _http.get(uri).timeout(const Duration(seconds: 30)); } on http.ClientException { throw SmsProviderException('NETWORK'); // the original message contains the URI, api_key included } Object? body; try { body = jsonDecode(res.body); // a JSON object or a quoted string } on FormatException { body = res.body.trim(); // plain text: STATUS_OK:123456, NO_NUMBERS... } if (res.statusCode >= 400) { // Errors are JSON {"title":"BAD_KEY","details":"..."} with HTTP 4xx/5xx final code = body is Map ? '${body['title']}' : 'HTTP_${res.statusCode}'; throw SmsProviderException(code, res.statusCode); } return body; } /// service: code from action=getServicesList; country: ID from action=getCountries Future<Activation> rentNumber({required String service, required int country}) async { final body = await _call( {'action': 'getNumberV2', 'service': service, 'country': '$country'}); if (body is! Map) throw SmsProviderException('$body'); // NO_NUMBERS: a string, HTTP 200 return Activation( '${body['activationId']}', '+${body['phoneNumber']}', '${body['activationCost']}'); } /// Polls getStatus until STATUS_OK:<code>; keeps waiting on STATUS_WAIT_*. Future<String> waitForCode(String id, {Duration timeout = const Duration(minutes: 3)}) async { final deadline = DateTime.now().add(timeout); while (DateTime.now().isBefore(deadline)) { final status = '${await _call({'action': 'getStatus', 'id': id})}'; if (status.startsWith('STATUS_OK:')) return status.substring('STATUS_OK:'.length); // STATUS_CANCEL or anything unexpected: stop now instead of polling until the timeout if (!status.startsWith('STATUS_WAIT')) throw SmsProviderException(status); await Future<void>.delayed(const Duration(seconds: 5)); } throw TimeoutException('No SMS for activation $id', timeout); } /// 6 = complete (code used), 8 = cancel and refund (code not used) Future<void> close(Activation a, {required bool used}) async { Future<Object?> setStatus(String s) => _call({'action': 'setStatus', 'id': a.id, 'status': s}); try { await setStatus(used ? '6' : '8'); } on SmsProviderException catch (e) { if (e.code == 'EARLY_CANCEL_DENIED') { // No cancel in the first 2 minutes (minActivationTime: 120): wait past that mark, then cancel final wait = a.rentedAt.add(const Duration(seconds: 125)).difference(DateTime.now()); if (!wait.isNegative) await Future<void>.delayed(wait); await setStatus('8'); } else if (e.code == 'OTP_RECEIVED') { await setStatus('6'); // the SMS arrived: close the activation as completed } else { rethrow; } } } void dispose() => _http.close(); } 

The test reuses PhoneAuthService:

// integration_test/phone_auth_real_test.dart (real devices only) import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/foundation.dart' show debugPrint; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:my_app/firebase_options.dart'; // points to the Firebase project used for testing import 'package:my_app/phone_auth_service.dart'; import 'support/sms_provider.dart'; const otpService = String.fromEnvironment('OTP_SERVICE'); // code from getServicesList const otpCountry = int.fromEnvironment('OTP_COUNTRY', defaultValue: -1); // ID from getCountries void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized()Source:https://dev.to/willette_samer/firebase-phone-auth-testing-in-flutter-test-numbers-vs-real-sms-59jn

Related posts