The tru.ID SIMCheck API indicates whether the SIM card associated with a mobile phone number was changed within the last seven days. This check provides an extra layer of security in your application authentication flows and can be used to detect attempted SIM swap fraud.
In this case, we'll use the tru.ID SIMCheck API to augment an app secured with Firebase Phone Auth.
If you want to dive into the code, you can find it on GitHub.
Before you begin
To follow along with this tutorial, you'll need:
- A tru.ID Account
- A mobile phone with a SIM card with a mobile data connection
- A Firebase Account
In your terminal, clone the starter-files
branch with the following command:
git clone -b starter-files --single-branch https://github.com/tru-ID/firebase-phone-auth-sim-swap-detection-flutter.git
If you're only interested in the finished code in main
, then run:
git clone -b main https://github.com/tru-ID/firebase-phone-auth-sim-swap-detection-flutter.git
You'll also need the tru.ID CLI, which is used to create a project and run a local server for development. Install this CLI in your terminal with the following command:
npm i -g @tru_id/cli
Enter your tru.ID credentials, which can be found within the tru.ID console.
$ tru setup:credentials {YOUR_CLIENT_ID} {YOUR_CLIENT_SECRET} EU
Install the tru.ID CLI development server plugin
Create a new tru.ID project within the root directory via:
tru projects:create firebase-auth-flutter --project-dir .
If you want to create a tru.ID project with Sandbox mode enabled for testing, run:
tru projects:create firebase-auth-flutter --mode sandbox --project-dir
You can read more about our Sandbox mode here.
Run the development server, pointing it to the directory containing the newly created project configuration. This command will also open up a LocalTunnel
to your development server, making it publicly accessible to the Internet so that your mobile phone can access it when only connected to mobile data. Please note the LocalTunnel
URL, as you will need to use it later in the tutorial.
tru server -t
Getting started with FlutterFire
This project uses FlutterFire, a set of Flutter plugins connecting your Flutter application to Firebase. To get your project up and running check out the overview guide, how to get set up for Android, iOS, and the Authentication Setup.
Note Flutter with null safety is preferred.
Run the app
To start the project, ensure you have a physical device connected, then run:
flutter pub get$ flutter run
Note To run this application on an iOS device, please ensure you've provisioned your project in XCode.
On your physical device you'll see an application open, which will look the same as what's shown in the example below:

Get the user's phone number
Update the baseURL
value in lib/registration.dart
to the value of the LocalTunnel
URL you noted earlier in the tutorial. This URL tells your mobile application how to communicate with the backend server.
final String baseURL = 'https://busy-beaver-68.loca.lt';
Next, you need to store the user's phone number when it is input. You'll need to add the pieces of state to store the phone number and SMS OTP. Flutter's TextEditingController is the perfect solution for this. Two booleans are also needed, which will be defaulted by false. These booleans are loading
to trigger a visual cue and proceedWithFirebaseAuth
, to determine whether the SIM check was successful or not.
Locate the line class LoginState extends State<Login> {
, and above the @override
call, add the following:
final phoneNumber = TextEditingController();final otp = TextEditingController();String? phoneNumberValue;int? resendingToken;bool proceedWithFirebaseAuth = false;bool loading = false;void dispose() {phoneNumber.dispose();otp.dispose();super.dispose();}
Here you create the relevant states and override a dispose
function, which disposes of the controllers once they're no longer needed, typically because the app has moved to a new route.
The next step is to bind the phone number controller to the TextField
. Update the TextField
widget in the widget tree to the following:
child: TextField(keyboardType: TextInputType.phone,controller: phoneNumber,decoration: const InputDecoration(border: OutlineInputBorder(),hintText: 'Enter your phone number.',),)),
Lastly, set up the visual indicator to display when something is happening. Replace the child
prop of the TextButton
widget with the following:
child: loading? const CircularProgressIndicator(): const Text('Login'),
If loading
is true
in the above code, the CircularProgressIndicator
widget gets displayed, which acts as the visual cue to the user.
You also need to create a reusable function to handle successful authentication to keep code from being repeated (see the DRY principle). For this, above class _LoginState extends State<Login>
, paste the following:
// Success scenario UIFuture<void> successHandler(BuildContext context) {return showDialog(context: context,builder: (BuildContext context) {return AlertDialog(title: const Text('Login Successful.'),content: const Text('✅'),actions: <Widget>[TextButton(onPressed: () => Navigator.pop(context, 'Cancel'),child: const Text('Cancel'),),TextButton(onPressed: () => Navigator.pop(context, 'OK'),child: const Text('OK'),),],);});}
Authenticating with Firebase Phone Auth
When the user clicks the TextButton
, it needs to trigger a verification request with Firebase. This check verifies the user's phone number and sends them an SMS OTP that is auto-read on specific Android devices, or otherwise typically handled via TextField
.
To handle this second scenario, you'll create a reusable function that opens a dialog prompting the user to input their number, and attempts to log the user in with Firebase once the dialog box has closed.
At the top of your lib/login.dart
file, add the following import, which contains a helper class for handling errors:
import 'package:flutterfire/helpers.dart';
Find class LoginState extends State<Login>
, and below void dispose() {}
, add the following:
// OTP Screen handlerFuture<void> otpHandler(BuildContext context, FirebaseAuth auth, String verificationId) {return showDialog(context: context,builder: (BuildContext context) {return AlertDialog(title: const Text("Please Enter OTP"),content: TextField(keyboardType: TextInputType.phone,controller: otp,decoration: const InputDecoration(border: OutlineInputBorder(),hintText: 'Enter OTP',),),actions: <Widget>[TextButton(onPressed: () async {// create a PhoneAuthCredential with the otpPhoneAuthCredential credential = PhoneAuthProvider.credential(verificationId: verificationId, smsCode: otp.text);try {// sign in the userawait auth.signInWithCredential(credential);setState(() {loading = false;});} catch (e) {print(e);setState(() {loading = false;});return errorHandler(context, "Unable to sign you in.","Unable to sign you in at this moment. Please try again");}successHandler(context);return Navigator.pop(context, 'OK');},child: const Text('OK'),),],);});}
Next, you will be using Firebase, but first, the firebase_auth
library needs importing. At the top of lib/login.dart
, add the following import:
import 'package:firebase_auth/firebase_auth.dart';
Now, within the same file, find the line onPressed: () async {},
, and replace it with the following:
onPressed: () async {// create a Firebase Auth instanceFirebaseAuth auth = FirebaseAuth.instance;await auth.verifyPhoneNumber(phoneNumber: phoneNumber.text!,timeout: const Duration(seconds: 120),verificationCompleted:(PhoneAuthCredential credential) async {// Android only method that auto-signs in on Android devices that support itawait auth.signInWithCredential(credential);setState(() {loading = false;});return successHandler(context);},verificationFailed: (FirebaseAuthException e) {setState(() {loading = false;});errorHandler(context, 'Something went wrong.','Unable to verify your phone number');return;},codeSent:(String verificationId, int? resendToken) async {// save resendToken to statesetState(() {resendingToken = resendToken;});print("your resend token is: ");print(resendToken);// render OTP dialog UIotpHandler(context, auth, verificationId);},codeAutoRetrievalTimeout: (String verificationId) {},);}
Here, you create a new Firebase instance, and you call the verifyPhoneNumber
method. Android devices that support automatic SMS OTP resolution read the OTP and call the verificationCompleted
callback attempting to sign the user in.
The verificationFailed
callback is then triggered to handle failure events such as invalid phone numbers or whether the SMS quota has exceeded. In this callback, you set loading
to false, and render an error via our reusable error handler defined in lib/helpers.dart
.
The next callback, codeSent
, handles instances when Firebase has sent the SMS OTP to the user’s device. Here you save the resendToken
to state as Firebase uses this value to force resending of OTPs. The OTP handler function is then triggered, which logs the user in with Firebase.
The UI for the OTP screen and the UI for an unsuccessful attempt will look as follows:


Adding SIM swap detection
Now it's time to add SIM swap detection using the tru.ID SIMCheck API.
The SIM swap detection will happen before the Firebase Authentication, adding the first check before Firebase Phone Auth can happen. Once SIM swap detection has passed, Firebase Phone Auth will continue.
Before creating the SIMCheck, though, the application needs to call the Reachability API to check the user's mobile network operator supports the SIMCheck API. To do this, you'll need to install the tru.ID Flutter SDK. To install this SDK in your terminal, run the following command:
flutter pub add tru_sdk_flutter#ordart pub add tru_sdk_flutter
Add the imports for the tru.ID SDK, and a library that allows you to convert a string to JSON to the top of lib/login.dart
:
import 'package:tru_sdk_flutter/tru_sdk_flutter.dart';import 'dart:convert';
Update the onPressed
handler to the following:
onPressed: () async {setState(() {loading = true;});// check if we have coverageTruSdkFlutter sdk = TruSdkFlutter();String? reachabilityInfo = await sdk.isReachable();ReachabilityDetails reachabilityDetails =ReachabilityDetails.fromJson(jsonDecode(reachabilityInfo!));if (reachabilityDetails.error?.status == 400) {return errorHandler(context, "Something Went Wrong.","Mobile Operator not supported.");}bool isSIMCheckSupported = false;if (reachabilityDetails.error?.status != 412) {isSIMCheckSupported = false;for (var products in reachabilityDetails.products!) {if (products.productName == "Sim Check") {isSIMCheckSupported = true;}}} else {isSIMCheckSupported = true;}if (isSIMCheckSupported) {// create a Firebase Auth instanceFirebaseAuth auth = FirebaseAuth.instance;await auth.verifyPhoneNumber(phoneNumber: phoneNumber.text,timeout: const Duration(seconds: 120),verificationCompleted:(PhoneAuthCredential credential) async {// Android only method that auto-signs in on Android devices that support itawait auth.signInWithCredential(credential);setState(() {loading = false;});return successHandler(context);},verificationFailed: (FirebaseAuthException e) {setState(() {loading = false;});errorHandler(context, 'Something went wrong.','Unable to verify your phone number');return;},codeSent:(String verificationId, int? resendToken) async {// save resendToken to statesetState(() {resendingToken = resendToken;});print("your resend token is: ");print(resendToken);// render OTP dialog UIotpHandler(context, auth, verificationId);},codeAutoRetrievalTimeout: (String verificationId) {},);} else {// SIMCheck is not supported by MNO. Do not bother creating SIMCheck just proceed with Firebase Auth// create a Firebase Auth instanceFirebaseAuth auth = FirebaseAuth.instance;await auth.verifyPhoneNumber(phoneNumber: phoneNumber.text,timeout: const Duration(seconds: 120),verificationCompleted:(PhoneAuthCredential credential) async {// Android only method that auto-signs in on Android devices that support itawait auth.signInWithCredential(credential);setState(() {loading = false;});return successHandler(context);},verificationFailed: (FirebaseAuthException e) {setState(() {loading = false;});errorHandler(context, 'Something went wrong.','Unable to verify your phone number');return;},codeSent:(String verificationId, int? resendToken) async {// save resendToken to statesetState(() {resendingToken = resendToken;});print("your resend token is: ");print(resendToken);// render OTP dialog UIotpHandler(context, auth, verificationId);},codeAutoRetrievalTimeout: (String verificationId) {},);}},
In the code above, the isReachable
function gets called, which returns the networkId
, networkName
, countryCode
, and products
when checking whether the device is reachable through a cellular connection. The products
array is an optional array of tru.ID's products supported by the user's mobile network operator (MNO). There is also an error
object which may contain any potential errors.
The application then creates the variable isSIMCheckSupported
, defaulting it to false
. If the error status is not 400
(Unsupported by MNO), or 412
(Not a cellular IP address), the application loops through the products and checks whether the productName
equals Sim Check
. If it does, then the value of isSIMCheckSupported
is set to true
.
If the isSIMCheckSupported
variable gets set to false
by the end of the loop, it means SIMCheck is not supported, and as such, it proceeds with Firebase Auth. If isSIMCheckSupported
is true
, the flow will be to create the SIMCheck and proceed with Firebase Auth.
At the moment, the application proceeds with Firebase Auth, so it is now time to implement the SIMCheck
functionality.
Create the SIMCheck
The next step is to create the SIMCheck
resource. A data class is needed to represent the expected properties and convert the JSON response to a Dart Map.
Create a new file, models.dart
, in the lib
directory, and enter the following:
import 'dart:convert';SIMCheck SIMCheckFromJSON(String jsonString) =>SIMCheck.fromJSON(json.decode(jsonString));class SIMCheck {bool simChanged;SIMCheck({required this.simChanged});factory SIMCheck.fromJSON(Map<String, dynamic> jsonObject) =>SIMCheck(simChanged: !jsonObject["no_sim_change"]);}
In the above code snippet, a SIMCheck
class is created with a simChanged
boolean, representing the no_sim_change
value retrieved from the SIMCheck API response.
A factory constructor is then created, initializing final variables from the JSON object passed in.
Lastly, a function is created that takes in a JSON string and passes in the decoded JSON object to the factory constructor.
Back in the lib/login.dart
file, import the newly created models.dart
file at the top:
import 'package:flutterfire/models.dart';
The next step is to create a function for creating the SIMCheck resource. For that, we need to bring in a package to help make HTTP network requests.
In the terminal, run the following:
flutter pub add http#ordart pub add http
Import the new library at the top of lib/login.dart
:
import 'package:http/http.dart' as http;
Locate the line class _LoginState extends State<Login>
and above this, add the new createSIMCheck
function shown below:
Future<SIMCheck?> createSIMCheck(String phoneNumber) async {final response = await http.post(Uri.parse('$baseURL/sim-check'),body: {"phone_number": phoneNumber});if (response.statusCode != 200) {return null;}final String data = response.body;return SIMCheckFromJSON(data);}
The final step is to call this function. Inside the top of the if (isSIMCheckSupported) {}
block, add the following:
if (isSIMCheckSupported) {// SIMCheck is supported, create SIMCheckSIMCheck? SIMCheckResult =await createSIMCheck(phoneNumber.text);if (SIMCheckResult == null) {setState(() {loading = false;});return errorHandler(context, 'Something went wrong.','Phone number not supported');}if (SIMCheckResult.simChanged) {setState(() {loading = false;phoneNumberValue = phoneNumber.text;});phoneNumber.clear();return errorHandler(context, 'Something went wrong','SIM changed too recently.');}//The SIM hasn't changed in 7 days, proceed with Firebase Auth...}
Here, the application calls the function, and if SIMCheckResult
is null, it returns the errorHandler
. A check is then made to see if simChanged
is true
. If it is, the state is updated and returned in the errorHandler
. If this happens, it's because the user's SIM has changed recently.
If simChanged
is not true
, this indicates the phone number has not changed in the past seven days, so the flow proceeds with Firebase Phone Auth.
Note Make sure to set
forceResendingToken
prop to theresendingToken
state value on theverifyPhoneNumber
function to force Firebase to resend the OTP.
The workflow from start to finish for a successful workflow is:




Wrapping up
There you have it: you’ve successfully integrated tru.ID SIMCheck with your Flutter applications secured with Firebase Phone Authentication, and now you have SIM swap detection for a more secure login flow.