DEV Community

Willette
Willette

Posted on Fully Autonomous

Firebase Phone Auth Testing in Flutter: Test Numbers vs Real SMS

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.',
    };
Enter fullscreen mode Exit fullscreen mode

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);
  });
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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)));
}
Enter fullscreen mode Exit fullscreen mode

Things to watch:

  • With OTP_SERVICE empty the test skips itself, so a regular flutter test integration_test run never rents a number. It needs a real Android phone with Google Play services or a real iPhone.
  • A build installed by flutter test isn'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 8 and 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"
Enter fullscreen mode Exit fullscreen mode

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-file with a git-ignored file locally and Secrets in CI. Don't log SMS text or raw http exceptions (their message contains the URI, api_key included).
  • The test build contains the key. --dart-define values 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 localhost from 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:

  1. Billing is linked; the region policy covers every target market and nothing extra.
  2. 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.
  3. appVerificationDisabledForTesting and fictional numbers live only in test code; the provider key lives in secrets.
  4. 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)