FirebasePhoneAuthHandler For Flutter
An easy-to-use firebase phone authentication package to easily send and verify OTP's with auto-fetch OTP support via SMS. Supports OTP on web out of the box.
- π· Screenshots
- β¨ Features
- π« Migration Guides
- π Getting Started
- π οΈ Platform-specific Setup
- β Usage
- π― Sample Usage
- π€ Author
| Demo | Sending OTP | Auto Fetch OTP |
|---|---|---|
![]() |
![]() |
![]() |
- Simple OTP Verification Process: This package simplifies phone number authentication with Firebase, automatically managing OTP request and verification for you.
- SMS Autofill Support: Automatically fetches and enters the received OTP from the SMS, streamlining the user experience on Android.
- Easy-to-use Callbacks: You can define custom callbacks like
onLoginSuccess,onLoginFailedetc., making the widget simple to use. - Configurable Resend OTP Timer: You can easily configure the time interval for OTP resend requests, ensuring users donβt spam the request button.
- Cross-Platform Support: It provides full support for Android, iOS and Web, ensuring a consistent experience across platforms.
- Widget-Based Approach: The package integrates well with Flutterβs UI-driven architecture, offering a widget-based solution for handling phone authentication.
- Seamless Integration: The package can be easily integrated into any Flutter app, allowing quick and reliable phone authentication with Firebase.
FirebasePhoneAuthHandler now creates and owns the controller it passes to your builder,
so there is nothing to wrap your app with. The widget is gone β delete it from your tree:
// before
FirebasePhoneAuthProvider(
child: MaterialApp(home: HomeScreen()),
)
// after
MaterialApp(home: HomeScreen())The one capability this removes is reading the controller from a widget that is not
inside a handler, because there is no longer a single app-wide instance. Use the controller
passed to builder, or context.watch<FirebasePhoneAuthController>() from within it.
That single shared instance was also the source of several bugs: a verification session could leak into the next screen, and two handlers mounted at the same time overwrote each other's configuration. Each handler now gets a fresh controller, disposed when it unmounts.
It existed only to reset the shared app-wide controller between handlers. Since each handler now disposes its own controller, there is nothing to reset. Calling it also left the controller without a phone number and therefore unusable, so it was a footgun rather than a useful reset. Delete the calls β unmounting the handler is the teardown.
It used to resolve the controller through the provider purely to reach
FirebaseAuth.instance.signOut(). Drop the argument:
// before
await FirebasePhoneAuthHandler.signOut(context);
// after
await FirebasePhoneAuthHandler.signOut();
// or, when using a secondary Firebase app
await FirebasePhoneAuthHandler.signOut(auth: myAuth);codeSent and isSendingCode remain as convenience shorthands, but they are now derived
from a new OtpSendStatus enum (idle, sending, sent, failed).
Two booleans could not express "nothing has happened yet" or "the last attempt failed" β
isSendingCode was literally defined as !codeSent, so a fresh controller reported that it
was sending. Any UI gated on it showed a "sending OTP" loader that never cleared when
sendOtpOnInitialize was false, or when a send failed. Switch on otpSendStatus where
that distinction matters:
body: switch (controller.otpSendStatus) {
OtpSendStatus.idle || OtpSendStatus.failed => RetryButton(),
OtpSendStatus.sending => Loader(),
OtpSendStatus.sent => OtpEntryField(),
},codeSent also changed from a settable field to a getter, so any code assigning to it
(controller.codeSent = true) no longer compiles. Such assignments always desynced the
controller from what had actually happened.
Firebase does not guarantee that it calls back at all. When device verification cannot
complete β for example on Android with no SHA-1 registered β neither codeSent nor
verificationFailed ever fires, and the controller would sit in sending forever.
sendOTP now accepts codeSendTimeout, defaulting to 60 seconds. On timeout the status
becomes OtpSendStatus.failed, onError receives a TimeoutException explaining the likely
cause, and sendOTP returns false. Pass null for the old unbounded behaviour.
- Requires Dart 3.8 / Flutter 3.32 or newer, up from Dart 3.2 / Flutter 3.16.
- iOS apps need a deployment target of 15.0, up from 13.0.
firebase_auth6 pulls in Firebase 12, whosefirebase-authandfirebase-corepackages require iOS 15. Without it the build fails withTarget Integrity (Xcode): The package product 'firebase-auth' requires minimum platform version 15.0. - Android needs Gradle 8.7 or newer (Flutter warns below 8.14).
minSdkmust be at least 23, which Flutter's own default of 24 already satisfies.
This package re-exports firebase_auth, so its breaking changes reach your code directly.
See its changelog for the full list.
Firebase phone auth needs a custom URL scheme registered in Info.plist, or the app
hard-crashes when it falls back to reCAPTCHA β which is always the case on the simulator.
This was always required by Firebase but was not documented here before. See
Platform-Specific Setup β iOS.
FirebasePhoneAuthHandler accepts an auth parameter, defaulting to FirebaseAuth.instance.
Pass one explicitly to verify against a secondary FirebaseApp, or to inject a fake in tests:
FirebasePhoneAuthHandler(
auth: FirebaseAuth.instanceFor(app: secondaryApp),
phoneNumber: '+911234567890',
builder: (context, controller) => ...,
);Nothing to change unless you need this β the default behaves exactly as before.
For more details, refer to the CHANGELOG.
Create a Firebase project. Learn more about Firebase projects here.
Add your Android, iOS, Web apps to your Firebase project and configure the Firebase the apps by following the setup instructions for Android, iOS and Web separately.
Important
Follow additional configration steps for Firebase Auth here
Open the Firebase Console, go to the Authentication section in your project. Select Sign-in method and enable Phone.
For Android, enable the Google Play Integrity API from Google Cloud Platform.
Add firebase_core as a dependency in your pubspec.yaml file.
dependencies:
flutter:
sdk: flutter
firebase_core:Call Firebase.initializeApp() in the main() method as shown to intialize Firebase in your project.
import 'package:firebase_core/firebase_core.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
runApp(MyApp());
}Android verifies the device before sending an SMS, using Play Integrity. That check is tied to the certificate your app is signed with, so Firebase needs your SHA-1 and SHA-256 fingerprints. Enabling the Play Integrity API (Step 4 above) is not sufficient on its own.
Get them with:
cd android && ./gradlew signingReportAdd both to Firebase Console β Project settings β Your apps β Android β Add fingerprint,
then re-download google-services.json and replace android/app/google-services.json.
Important
You need a fingerprint for every signing key you use: the debug keystore, your release keystore, and β if you use Play App Signing β the App signing key certificate shown under Play Console β Test and release β Setup β App signing. Google re-signs your upload with a different key, so phone auth can work locally and in internal testing yet fail for everyone once the app is live. That one is easy to miss.
There is no clear error. The send simply never completes: neither onCodeSent nor
onLoginFailed fires, and the controller stays in OtpSendStatus.sending. In logcat you
will see the device-verification fallback failing:
E/zza: Failed to initialize reCAPTCHA config: No Recaptcha Enterprise siteKey
configured for tenant/project *
Because Firebase never calls back, sendOTP would otherwise wait forever. It is bounded by
codeSendTimeout (60 seconds by default), after which otpSendStatus becomes
OtpSendStatus.failed and onError receives a TimeoutException.
firebase_auth requires minSdk 23. Flutter's own default (flutter.minSdkVersion) is
already 24, so this only matters if you have hardcoded a lower value in
android/app/build.gradle.
Real device verification needs a real, non-emulated device and correct fingerprints. For everything else, register a test number under Firebase Console β Authentication β Sign-in method β Phone β Phone numbers for testing. Test numbers skip Play Integrity and reCAPTCHA entirely, so they work on emulators and are the fastest way to tell a configuration problem apart from a code problem.
Two pieces of iOS setup are easy to miss, and neither fails in a way that points at the cause.
When APNs silent-push verification is unavailable β which is always the case on the simulator β Firebase falls back to a reCAPTCHA web flow and returns to your app through a custom URL scheme. If that scheme is not registered, the app hard-crashes:
FirebaseAuth/PhoneAuthProvider.swift:109: Fatal error: Please register custom URL
scheme app-1-1234567890-ios-abc123def456 in the app's Info.plist file.
The scheme is your GOOGLE_APP_ID from GoogleService-Info.plist with every : and .
replaced by -, prefixed with app-. Derive it rather than typing it out:
cd ios && echo "app-$(plutil -extract GOOGLE_APP_ID raw -o - Runner/GoogleService-Info.plist | tr ':.' '--')"Add it to ios/Runner/Info.plist:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>app-1-1234567890-ios-abc123def456</string>
</array>
</dict>
</array>Note: this is not the same as
REVERSED_CLIENT_ID. That scheme is for Google Sign-In. Registering onlyREVERSED_CLIENT_IDwill not stop the crash above.
If you re-run flutterfire configure and it changes which iOS app the project points at,
GOOGLE_APP_ID changes too and this scheme must be re-derived.
Firebase returns from the reCAPTCHA flow through the scheme above. FirebaseAuth consumes
that callback itself, but if Flutter's deep linking is enabled the engine also turns the
callback URL into a route name and pushes it, so your app navigates to a bogus route like
/link?deep_link_id=... on top of the OTP screen.
If your app does not use deep links, disable them in ios/Runner/Info.plist:
<key>FlutterDeepLinkingEnabled</key>
<false/>If your app does need deep links, leave that enabled and ignore the callback in your router instead:
if (name.startsWith('/link') && name.contains('deep_link_id')) {
// Firebase auth reCAPTCHA callback, not a route this app owns.
return null;
}firebase_auth 6 requires iOS 15.0. If IPHONEOS_DEPLOYMENT_TARGET is lower, the build
fails with Target Integrity (Xcode): The package product 'firebase-auth' requires minimum platform version 15.0.
The simulator has no APNs, so every send takes the reCAPTCHA path. The most reliable way to test is a test phone number: Firebase Console β Authentication β Sign-in method β Phone β Phone numbers for testing. Those bypass APNs and reCAPTCHA entirely.
On Web, the reCAPTCHA widget is a fully managed flow which provides security to your web application. The widget will render as an invisible widget when the sign-in flow is triggered. An "invisible" widget will appear as a full-page modal on-top of your application like demonstrated below.
Although, a RecaptchaVerifier instance can be passed which can be used to manage the widget.
Use the function recaptchaVerifierForWebProvider in FirebasePhoneAuthHandler which gives a boolean
to check whether the current platform is Web or not.
NOTE: Do not pass a RecaptchaVerifier instance if the platform is not web, else an error occurs.
Example:
recaptchaVerifierForWebProvider: (isWeb) {
if (isWeb) return RecaptchaVerifier();
},It is however possible to display an inline widget which the user has to explicitly press to verify themselves.
To add an inline widget, specify a DOM element ID to the container argument of the RecaptchaVerifier instance.
The element must exist and be empty otherwise an error will be thrown.
If no container argument is provided, the widget will be rendered as "invisible".
RecaptchaVerifier(
container: 'recaptcha',
size: RecaptchaVerifierSize.compact,
theme: RecaptchaVerifierTheme.dark,
onSuccess: () => print('reCAPTCHA Completed!'),
onError: (FirebaseAuthException error) => print(error),
onExpired: () => print('reCAPTCHA Expired!'),
),If the reCAPTCHA badge does not disappear automatically after authentication is done,
try adding the following code in onLoginSuccess so that it disappears when the login process is done.
Firstly import querySelector from dart:html.
import 'dart:html' show querySelector;Then add this in onLoginSuccess callback.
final captcha = querySelector('#__ff-recaptcha-container');
if (captcha != null) captcha.hidden = true;If you want to completely disable the reCAPTCHA badge (typically appears on the bottom right),
add this CSS style in the web/index.html outside any other tag.
<style>
.grecaptcha-badge { visibility: hidden; }
</style>- Add
firebase_phone_auth_handleras a dependency in your pubspec.yaml file.
dependencies:
flutter:
sdk: flutter
firebase_phone_auth_handler:- Use
FirebasePhoneAuthHandlerwidget in your widget tree and pass all the required parameters to get started.
FirebasePhoneAuthHandler(
// required
phoneNumber: "+919876543210",
// If true, the user is signed out before the onLoginSuccess callback is fired when the OTP is verified successfully.
signOutOnSuccessfulVerification: false,
linkWithExistingUser: false,
// required
builder: (context, controller) {
return SizedBox.shrink();
},
onLoginSuccess: (userCredential, autoVerified) {
debugPrint("autoVerified: $autoVerified");
debugPrint("Login success UID: ${userCredential.user?.uid}");
},
onLoginFailed: (authException, stackTrace) {
debugPrint("An error occurred: ${authException.message}");
},
onError: (error, stackTrace) {},
),- To logout the current user(if any), call
await FirebasePhoneAuthHandler.signOut();
// OR
controller.signOut(); // can also be used to logout the current user.See the example app for a complete app. Learn how to setup the example app for testing here.
Check out the full API reference of the widget here.
import 'package:firebase_phone_auth_handler/firebase_phone_auth_handler.dart';
import 'package:flutter/material.dart';
import 'package:phone_auth_handler_demo/screens/home_screen.dart';
import 'package:phone_auth_handler_demo/utils/helpers.dart';
import 'package:phone_auth_handler_demo/widgets/custom_loader.dart';
import 'package:phone_auth_handler_demo/widgets/pin_input_field.dart';
class VerifyPhoneNumberScreen extends StatefulWidget {
static const id = 'VerifyPhoneNumberScreen';
final String phoneNumber;
const VerifyPhoneNumberScreen({
super.key,
required this.phoneNumber,
});
@override
State<VerifyPhoneNumberScreen> createState() => _VerifyPhoneNumberScreenState();
}
class _VerifyPhoneNumberScreenState extends State<VerifyPhoneNumberScreen> with WidgetsBindingObserver {
bool isKeyboardVisible = false;
late final ScrollController scrollController;
@override
void initState() {
scrollController = ScrollController();
WidgetsBinding.instance.addObserver(this);
super.initState();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
scrollController.dispose();
super.dispose();
}
@override
void didChangeMetrics() {
final bottomViewInsets = WidgetsBinding.instance.platformDispatcher.views.first.viewInsets.bottom;
isKeyboardVisible = bottomViewInsets > 0;
}
// scroll to bottom of screen, when pin input field is in focus.
Future<void> _scrollToBottomOnKeyboardOpen() async {
while (!isKeyboardVisible) {
await Future.delayed(const Duration(milliseconds: 50));
}
await Future.delayed(const Duration(milliseconds: 250));
await scrollController.animateTo(
scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 250),
curve: Curves.easeIn,
);
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: FirebasePhoneAuthHandler(
phoneNumber: widget.phoneNumber,
signOutOnSuccessfulVerification: false,
sendOtpOnInitialize: true,
linkWithExistingUser: false,
autoRetrievalTimeOutDuration: const Duration(seconds: 60),
otpExpirationDuration: const Duration(seconds: 60),
onCodeSent: () {
log(VerifyPhoneNumberScreen.id, msg: 'OTP sent!');
},
onLoginSuccess: (userCredential, autoVerified) async {
log(
VerifyPhoneNumberScreen.id,
msg: autoVerified ? 'OTP was fetched automatically!' : 'OTP was verified manually!',
);
showSnackBar('Phone number verified successfully!');
log(
VerifyPhoneNumberScreen.id,
msg: 'Login Success UID: ${userCredential.user?.uid}',
);
Navigator.pushNamedAndRemoveUntil(
context,
HomeScreen.id,
(route) => false,
);
},
onLoginFailed: (authException, stackTrace) {
log(
VerifyPhoneNumberScreen.id,
msg: authException.message,
error: authException,
stackTrace: stackTrace,
);
switch (authException.code) {
case 'invalid-phone-number':
// invalid phone number
return showSnackBar('Invalid phone number!');
case 'invalid-verification-code':
// invalid otp entered
return showSnackBar('The entered OTP is invalid!');
// handle other error codes
default:
showSnackBar('Something went wrong!');
// handle error further if needed
}
},
onError: (error, stackTrace) {
log(
VerifyPhoneNumberScreen.id,
error: error,
stackTrace: stackTrace,
);
showSnackBar('An error occurred!');
},
builder: (context, controller) {
return Scaffold(
appBar: AppBar(
leadingWidth: 0,
leading: const SizedBox.shrink(),
title: const Text('Verify Phone Number'),
actions: [
if (controller.codeSent)
TextButton(
onPressed: controller.isOtpExpired
? () async {
log(VerifyPhoneNumberScreen.id, msg: 'Resend OTP');
await controller.sendOTP();
}
: null,
child: Text(
controller.isOtpExpired ? 'Resend' : '${controller.otpExpirationTimeLeft.inSeconds}s',
style: const TextStyle(color: Colors.blue, fontSize: 18),
),
),
const SizedBox(width: 5),
],
),
body: switch (controller.otpSendStatus) {
// Nothing in flight: either the OTP was never requested, or the
// last attempt failed. Both need an affordance to (re)send β
// previously these states rendered a loader that never cleared.
OtpSendStatus.idle || OtpSendStatus.failed => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
controller.otpSendStatus == OtpSendStatus.failed
? 'Could not send the OTP.'
: 'Tap below to send the OTP.',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 20),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: controller.sendOTP,
child: Text(
controller.otpSendStatus == OtpSendStatus.failed ? 'Retry' : 'Send OTP',
),
),
],
),
),
OtpSendStatus.sending => Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: const [
CustomLoader(),
SizedBox(height: 50),
Center(
child: Text(
'Sending OTP',
style: TextStyle(fontSize: 25),
),
),
],
),
OtpSendStatus.sent => ListView(
padding: const EdgeInsets.all(20),
controller: scrollController,
children: [
Text(
"We've sent an SMS with a verification code to ${widget.phoneNumber}",
style: const TextStyle(fontSize: 25),
),
const SizedBox(height: 10),
const Divider(),
if (controller.isListeningForOtpAutoRetrieve)
Column(
children: const [
CustomLoader(),
SizedBox(height: 50),
Text(
'Listening for OTP',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 25,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 15),
Divider(),
Text('OR', textAlign: TextAlign.center),
Divider(),
],
),
const SizedBox(height: 15),
const Text(
'Enter OTP',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 15),
PinInputField(
length: 6,
onFocusChange: (hasFocus) async {
if (hasFocus) await _scrollToBottomOnKeyboardOpen();
},
onSubmit: (enteredOtp) async {
final verified = await controller.verifyOtp(enteredOtp);
if (verified) {
// number verify success
// will call onLoginSuccess handler
} else {
// phone verification failed
// will call onLoginFailed or onError callbacks with the error
}
},
),
],
),
},
);
},
),
);
}
}Built and maintained by Rithik Bhandari, a mobile developer building cross-platform apps with Flutter.
- π Portfolio: rithikbhandari.dev
- π¦ More packages: pub.dev/publishers/rithikbhandari.dev
- π» GitHub: @rithik-dev
- πΌ LinkedIn: rithik-bhandari




