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:
- Link Cloud Billing (Blaze). Since September 2024, a project needs Cloud Billing to use the SMS service.
- Enable the Phone provider under Security > Authentication > Sign-in method.
- 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.
-
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. - 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.
-
Web: add your domain to Authorized domains. Per the docs,
localhostis 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,resendTokenis alwaysnull. -
codeAutoRetrievalTimeout: Android stopped waiting for auto-retrieval (timeoutdefaults 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
verifyPhoneNumberagain for the same number before the previous request times out sends no new SMS, so "Resend code" should callstart(phone, resend: true)to passforceResendingToken(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:
-
getNumberV2rents a number: JSON withactivationId,phoneNumber(no+),activationCost,activationEndTime. -
getStatusreturnsSTATUS_WAIT_CODEwhile waiting,STATUS_OK:<code>once the code is in,STATUS_CANCELif canceled. -
setStatuswith6completes the activation;8cancels 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();
// Do NOT enable appVerificationDisabledForTesting: real numbers would fail to sign in
setUpAll(() => Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform));
testWidgets('a real SMS OTP reaches the rented number and signs in', (tester) async {
// Check the config before spending money (never put the key in expect)
expect(otpCountry, isNonNegative, reason: '--dart-define=OTP_COUNTRY is missing');
final sms = SmsProvider();
Activation? activation;
var used = false;
try {
final a = await sms.rentNumber(service: otpService, country: otpCountry);
activation = a;
debugPrint('activationCost: ${a.cost}');
final auth = PhoneAuthService();
// Bounded: if it gets stuck on reCAPTCHA, the finally block still cancels the number
final auto = await auth.start(a.phone).timeout(const Duration(minutes: 2));
if (auto != null) {
// Android verified instantly, no SMS: this run can't check anything
await auto.user?.delete();
fail('instant verification, no SMS to check');
}
final code = await sms.waitForCode(a.id);
used = true; // the SMS arrived: close with status 6 even if a later step fails
final uc = await auth.confirm(code);
expect(uc.user?.phoneNumber, isNotNull);
await uc.user?.delete(); // remove the test user to keep the project clean
} finally {
if (activation != null) {
try {
await sms.close(activation, used: used);
} catch (e) {
debugPrint('Could not close the activation: $e'); // error code only, no key
}
}
sms.dispose();
await FirebaseAuth.instance.signOut();
}
},
// Without OTP_SERVICE the test is skipped, so a normal pipeline running the whole folder never rents a number
skip: otpService.isEmpty,
timeout: const Timeout(Duration(minutes: 8)));
}
Things to watch:
- With
OTP_SERVICEempty the test skips itself, so a regularflutter test integration_testrun never rents a number. It needs a real Android phone with Google Play services or a real iPhone. - A build installed by
flutter testisn't distributed through the Play Store, so Android uses reCAPTCHA, not Play Integrity. If a challenge appears and nobody solves it,start()times out after 2 minutes and the number is canceled. Keep a person near the device to solve it by hand; never bypass it automatically. Check Play Integrity with a build from an internal testing track. - The 5-second poll interval is my choice, not a documented recommendation.
Honest limits:
- An activation lasts about 20 minutes (exact end in
activationEndTime); the 3-minute wait fits easily. - A number that gets no SMS is canceled with status
8and the price returns to your balance. One that got the SMS is billed, even if your app fails afterwards. - Some services reject virtual numbers, and the rented number's carrier may differ from your users'. It's a smoke test, not a guarantee for every SIM.
- Every run costs one Firebase SMS plus one number, so schedule it instead of running it per commit.
Run the real-SMS test on a schedule in CI
The fictional-number test runs on an emulator in your normal pipeline. The real-number test needs a real phone, so it lives on a self-hosted runner with an Android device plugged in, triggered weekly or by hand before a release:
# .github/workflows/phone-auth-real-sms.yml
name: phone-auth-real-sms
on:
workflow_dispatch: # run by hand before a release
schedule:
- cron: '0 1 * * 1' # 01:00 UTC on Monday = 08:00 in Vietnam
concurrency:
group: phone-auth-real-sms # one phone: never let two runs overlap
cancel-in-progress: false
jobs:
real-sms:
runs-on: [self-hosted, android-device] # runner with Flutter installed and a real Android phone plugged in
strategy:
fail-fast: false
max-parallel: 1 # one device: one country at a time
matrix:
country: [VN, ID] # every country must be allowed by the region policy
steps:
- uses: actions/checkout@v7
- run: flutter pub get
- name: Integration test with a real number (${{ matrix.country }})
env:
SMS_API_URL: ${{ vars.SMS_API_URL }}
SMS_API_KEY: ${{ secrets.SMS_API_KEY }} # Secrets only
OTP_SERVICE: ${{ vars.OTP_SERVICE }}
OTP_COUNTRY: ${{ vars[format('OTP_COUNTRY_{0}', matrix.country)] }}
DEVICE_ID: ${{ vars.ANDROID_DEVICE_ID }}
run: |
test -n "$OTP_SERVICE" || { echo 'OTP_SERVICE is not configured'; exit 1; } # if it's missing, the test is skipped and the job still goes green
flutter test integration_test/phone_auth_real_test.dart -d "$DEVICE_ID" \
--dart-define=SMS_API_URL="$SMS_API_URL" \
--dart-define=SMS_API_KEY="$SMS_API_KEY" \
--dart-define=OTP_SERVICE="$OTP_SERVICE" \
--dart-define=OTP_COUNTRY="$OTP_COUNTRY"
DEVICE_ID is the phone's serial; each market gets an OTP_COUNTRY_<code> repository variable with its ID from getCountries. OTP_SERVICE must be the service code for your Firebase SMS sender, since an activation is reserved for one service. Swap [VN, ID] for your markets, and pick a cron time when someone is near the phone in case a reCAPTCHA challenge appears.
What a Firebase OTP test run costs
Firebase bills every phone auth SMS by destination country (Identity Platform pricing). Prices on November 12, 2026 (recheck, they change):
| Country | Price per SMS |
|---|---|
| Vietnam | $0.13 |
| Indonesia | $0.35 |
| India | $0.07 |
| Türkiye | $0.01 |
| United States | $0.01 |
- The same page says the first ten SMS per day are not billed. That isn't a Spark free tier: the project still needs billing.
- Per the limits page, Firebase Authentication sends up to 3,000 SMS per day (no limit with Identity Platform), and each IP address is capped at 50 SMS per minute and 500 per hour.
- Every "Resend code" tap is another SMS. The rented number is billed separately; the test prints its
activationCost.
Security and responsible use
-
Keep the key out of the repo and logs. Use
--dart-define-from-filewith a git-ignored file locally and Secrets in CI. Don't log SMS text or rawhttpexceptions (their message contains the URI,api_keyincluded). -
The test build contains the key.
--dart-definevalues are compiled into the binary. Don't upload that build as an artifact, keep a small balance, and rotate the key if it might have leaked. - Use it on your own app only. Rented numbers are for test accounts in apps you own or are authorized to test. Don't use them to mass-register accounts, to get around another service's verification, or for banking and identity-bound services. Don't test with colleagues' or customers' numbers either.
- Never run this against production. Use a separate test project with the same billing, region policy and app verification setup.
Protect production from SMS pumping
SMS pumping (toll fraud) is bots hitting "Send code" in bulk for number ranges that pay the attacker a cut, while you pay the bill. What helps:
- Region policy: allowlist only the markets you serve.
- Watch verified/sent per region. The FAQ calls 70 to 85% normal; below 50% is a common sign of bots or pumping.
-
Remove
localhostfrom Authorized domains in production (projects created after April 28, 2025 don't include it). - App Check for Firebase Authentication is in Preview, requires Identity Platform, and doesn't stop flows started and abandoned just to generate SMS.
- reCAPTCHA SMS defense (also Preview) scores each SMS request for toll-fraud risk and, in enforce mode, sends nothing above your threshold. It needs the reCAPTCHA Enterprise API and Android Auth 23.1.0+ or iOS 11.6.0+.
FAQ
Is Firebase phone auth free?
Not really. Since September 2024 you need Cloud Billing (Blaze) to send SMS. The first ten SMS per day aren't billed; after that you pay per SMS, by country. Fictional numbers cost nothing.
Can I test Firebase SMS OTP on the Android emulator?
Yes, with a fictional number and appVerificationDisabledForTesting: true. Real numbers only work on real devices and the web.
Wrap-up
Test Firebase phone auth in two layers: fictional numbers on every PR, and real temporary numbers per market before release. Only the second sends a real SMS with app verification on. Pre-release checklist:
- Billing is linked; the region policy covers every target market and nothing extra.
- SHA-1/SHA-256 for every signing key (including the Play App Signing key from Play Console), the APNs key and Authorized domains are in place.
-
appVerificationDisabledForTestingand fictional numbers live only in test code; the provider key lives in secrets. - After release, you track verified/sent per region.
How do you test Firebase phone auth today: fictional numbers only, a shared phone in a drawer, or something else?
Top comments (0)