SIM Based Authentication with Flutter using Supabase

In this tutorial, you'll learn how to add SIM card-based mobile authentication to a Flutter application signup workflow with Supabase. You'll use tru.ID PhoneCheck to verify the phone number associated with the SIM card on a mobile device.
Timothy OgbemudiaDeveloper Experience Engineer
Last updated: 7 December 2021
tutorial cover image

This tutorial will show you how to augment an app built with Flutter that uses Supabase email and password authentication. You’ll learn how to add an extra layer of protection with SIM card authentication using the tru.ID PhoneCheck API.

The tru.ID PhoneCheck API confirms the ownership of a mobile phone number by confirming the presence of an active SIM card with the same number. It creates a seamless signup onboarding flow with minimal UX friction, resulting in reduced user drop-offs compared to alternatives such as SMS OTP.

For this verification, the mobile network operator creates a mobile data session to a unique Check URL. tru.ID then resolves a match between the phone number used for verification and the phone number that the mobile network operator identifies as the owner of the mobile data session.

If you're only interested in the finished code, you can find it on GitHub.

Before you begin

To follow along with this tutorial, you'll need:

Getting started

In your terminal, clone the starter-files branch with the following command:

$ git clone -b starter-files https://github.com/tru-ID/supabase-passwordless-authentication-flutter.git

If the finished code is what you're interested in, then the command below will clone the repository with the main branch as the active branch:

$ git clone -b main https://github.com/tru-ID/supabase-passwordless-authentication-flutter.git

A tru.ID Account is needed to make the PhoneCheck API requests, so make sure you've created one.

You'll also need the tru.ID CLI, which is used to create a project and run a local server for development. To install this CLI, run the following command in your terminal:

$ npm i -g @tru_id/cli

Set up the CLI with the tru.ID credentials, which you can find within the tru.ID console.

$ tru setup:credentials {YOUR_CLIENT_ID} {YOUR_CLIENT_SECRET} EU

You'll be using the development server plugin for this tutorial, which runs a development server from the tru.ID CLI. You can find instructions to install the development server on our GitHub page.

Now, create a new tru.ID project within the root directory via:

$ tru projects:create flutter-supabase-auth --project-dir .

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 this tutorial.

$ tru server -t

Setting up Supabase

As previously stated, a Supabase account is needed. So, if you haven't already, create a Supabase account.

Once logged in, you'll see an interface like the one shown below:

A screenshot of a desktop browser having navigated to `app.supabase.io`. This is the Supabase dashboard, a black background, navigation on the left showing the `All Projects`. Then on the page is a green button with white text labelled `New Project`.

Select ‘New project’, which will take you to a page similar to the one shown below. On this page, enter your desired credentials for this project.

A screenshot of a desktop browser having navigated to create a new project.. This is the Supabase dashboard, a black background. On the page is a dark grey card, with the header 'Create a new project', then some input fields for the user to enter their project name, a database password, and the region for storing this information.

You'll then get redirected to the dashboard. Once there, click on the user icon on the left or the ‘Try Auth’ button.

A screenshot of a desktop browser having just created a new project. The header shows the project name in white text. Below are various cards for setting up the project.

You're then taken to the following page, which shows settings for your project such as the Site URL and Email Auth. Under ‘Email Auth’, toggle off ‘Enable Email Confirmations’, as shown below:

A screenshot of a desktop browser showing the new Supabase project settings. This page displays multiple input fields for the settings of the projects such as 'Site URL' and 'JWT Expiry'.

Next, in your project directory, copy the file .env.example to .env:

$ cp .env.example .env

Replace SUPABASE_URL with the URL value found under Settings > API > Config > URL in your project dashboard. Also, replace SUPABASE_PUBLIC_ANON with the anon public value found under Settings > API > Project API keys > anon public.

Start the project

You've now completed the configuration and setup parts required to begin this tutorial. It's time to get started with the project. In your terminal, make sure you're currently in the project directory, and run the following command to test the starter-files application:

$ flutter run

Note For a physical iOS device, ensure you've provisioned your project in XCode.

On your phone, you'll see a screen similar to the one displayed below:

A screenshot of a mobile app with a grey background. The tru.ID logo is centered at the top, then below this is a darker grey label with 'Sign Up'.

Get the user's credentials on the mobile device

The next step is to determine how to receive a user's credentials on the mobile device. To store this information, you'll be using state management to the store, and some UI changes are also required for the user to input their information.

Before this happens, update the baseURL value in lib/registration.dart to the value of the LocalTunnel URL you noted earlier in the tutorial.

final String baseURL = '<YOUR_LOCALTUNNEL_URL>';

Authentication with Supabase Auth will require the user to input an email and their desired password. They'll also need to input their phone_number, required to perform the PhoneCheck.

Open lib/registration.dart and replace the contents of class _RegistrationState extends State<Registration> with the following:

String phoneNumber = '';
String email = '';
String password = '';
bool loading = false;
Widget build(BuildContext context) {
return Scaffold(
body: Container(
child: ListView(
children: [
Container(
padding: const EdgeInsets.only(bottom: 45.0),
margin: const EdgeInsets.only(top: 40),
child: Image.asset(
'assets/images/tru-id-logo.png',
height: 100,
)),
Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 20),
child: const Text(
'Register.',
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 30,
),
)),
Container(
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 40, vertical: 10),
child: TextField(
keyboardType: TextInputType.emailAddress,
onChanged: (text) {
setState(() {
email = text;
});
},
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Enter your email.',
),
),
),
),
Container(
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 40, vertical: 10),
child: TextField(
keyboardType: TextInputType.text,
obscureText: true,
onChanged: (text) {
setState(() {
password = text;
});
},
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Enter your password.',
),
),
),
),
Container(
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 40, vertical: 10),
child: TextField(
keyboardType: TextInputType.phone,
onChanged: (text) {
setState(() {
phoneNumber = text;
});
},
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Enter your phone number.',
),
),
),
),
Container(
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
child: TextButton(
onPressed: () async {
},
child: loading
? const CircularProgressIndicator()
: const Text('Register')),
),
)
],
),
),
);
}

The UI should now look like this:

the tru.ID logo with a text that says 'Sign Up' with an email text input and a password text input and a button that says 'sign up'

Create reusable functions

You now need to create reusable functions for handling two possible outcomes.

The first possible outcome is an errorHandler function, which takes in the current BuildContext, title, and content, and returns an AlertDialog widget.

The second possible outcome is the successHandler, function which returns a generic AlertDialog.

Above class _RegistrationState extends State<Registration>, add the following snippet of code which contains the two functions for these possible outcomes:

Future<void> errorHandler(BuildContext context, String title, String content) {
return showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(title),
content: Text(content),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context, 'Cancel'),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, 'OK'),
child: const Text('OK'),
),
],
);
});
}
Future<void> successHandler(BuildContext context) {
return showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Registration 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'),
),
],
);
});
}

Handle user registration

The next step is to handle user signup. We'll use Supabase Auth to handle user signup using email and password.

For this, update the TextButton widget's onPressed: () async {} function to the following:

onPressed: () async {
setState(() {
loading = true;
});
GotrueSessionResponse result =
await supabase.auth.signUp(email, password);
if (result.error != null) {
setState(() {
loading = false;
});
return errorHandler(context,
"Something went wrong.", result.error!.message);
}
if (result.data?.user != null) {
setState(() {
loading = false;
});
return successHandler(context);
}
},

Here you set the value of loading to true, which triggers the CircularProgressIndicator widget, a visual cue that an HTTP request has begun.

We then sign the user up using the email and password obtained from the text inputs.

If the check is unsuccessful, the response returns an error, which calls the errorHandler function and passes in the error message.

If the check is successful, the response returns a user, which disables the loading icon to false and calls the successHandler function.

the tru.ID logo with a text that says 'Sign Up' with an email text input and a password text input, a button that says 'sign up' and a modal overlaid with the text 'login successful' and a check mark emoji.

Adding SIM card-based mobile authentication to our workflow

Now that we can successfully sign up a user using Supabase, we would like to augment this flow to add mobile authentication using the tru.ID PhoneCheck API.

For this, the first step is to make a request to the tru.ID reachability API to ensure the user's mobile operator supports the PhoneCheck API.

Install the package from the terminal via:

$ flutter pub add tru_sdk_flutter
#or
dart pub add tru_sdk_flutter

Add the import tru.ID SDK to the top of lib/registration.dart:

import 'package:tru_sdk_flutter/tru_sdk_flutter.dart';

Update the TextButton widget's onPressed handler to the following:

onPressed: () async {
setState(() {
loading = true;
});
TruSdkFlutter sdk = TruSdkFlutter();
String? reachabilityInfo = await sdk.isReachable();
print("-------------REACHABILITY RESULT --------------");
print(reachabilityInfo);
ReachabilityDetails reachabilityDetails =
ReachabilityDetails.fromJson(
jsonDecode(reachabilityInfo!));
if (reachabilityDetails.error?.status == 400) {
setState(() {
loading = false;
});
return errorHandler(context, "Something Went Wrong.",
"Mobile Operator not supported.");
}
bool isPhoneCheckSupported = true;
if (reachabilityDetails.error?.status != 412) {
isPhoneCheckSupported = false;
for (var products in reachabilityDetails.products!) {
if (products.productName == "Phone Check") {
isPhoneCheckSupported = true;
}
}
} else {
isPhoneCheckSupported = true;
}
if(isPhoneCheckSupported){
} else {
final PhoneCheck? phoneCheckResponse =
await createPhoneCheck(phoneNumber);
if (result.error != null) {
setState(() {
loading = false;
});
return errorHandler(context, "Something went wrong.",
result.error!.message);
}
if (result.data?.user != null) {
setState(() {
loading = false;
});
return successHandler(context);
}
}
},

In the code sample above, the isReachable function is called. This function returns the network_id, network_name, country_code, and products. The products array is an optional array supported by the MNO. There is also an error object which contains any potential errors.

Next, the application creates the variable isPhoneCheckSupported. If the error status is not a 412, the application loops through the products and checks whether the product equals Phone Check; if it does, then isPhoneCheckSupported is set to true.

If the isPhoneCheckSupported variable gets set to false by the end of the loop, the PhoneCheck is not supported. If isPhoneCheckSupported is true, the PhoneCheck can be created before proceeding with Supabase Auth. Else, we proceed with Supabase Auth since the API is unsupported.

Create the PhoneCheck

First, create a data class representing the expected properties and convert the JSON response to a Dart Map.

Create a new file called models.dart in the lib directory and paste the following:

import 'dart:convert';
// function for converting from JSON to an object (or map)
PhoneCheck phoneCheckFromJSON(String jsonString) =>
PhoneCheck.fromJson(jsonDecode(jsonString));
class PhoneCheck {
String checkId;
String checkUrl;
PhoneCheck({required this.checkId, required this.checkUrl});
factory PhoneCheck.fromJson(Map<String, dynamic> json) => PhoneCheck(
checkId: json["check_id"],
checkUrl: json["check_url"],
);
}

The code you've just written creates a PhoneCheck class with two fields, the checkId and checkUrl, representing the check_id and check_url values retrieved from the PhoneCheck API response.

It also creates a factory constructor, which initializes final variables from a JSON object passed over.

It's now time to create a function that takes in a JSON string and passes the decoded JSON object to the factory constructor. At the top of lib/registration.dart, add the import for your newly created models.dart:

import 'package:supabase_flutter_phonecheck/models.dart';

The next step is to create a function for creating the PhoneCheck resource. The HTTP package is needed to make HTTP network requests. In the terminal, run the following to install this package:

$ flutter pub add http
#or
dart pub add http

Import this new class at the top of lib.registration.dart:

import 'package:http/http.dart' as http;

Above the line Future<void> errorHandler(BuildContext context, String title, String content) {, create the following new function, createPhoneCheck:

Future<PhoneCheck?> createPhoneCheck(String phoneNumber) async {
final response = await http.post(Uri.parse('$baseURL/phone-check'),
body: {"phone_number": phoneNumber});
if (response.statusCode != 200) {
return null;
}
final String data = response.body;
return phoneCheckFromJSON(data);
}

You now need to update the onPressed handler within the TextButton widget to call your newly created createPhoneCheck function. Add this as shown below:

onPressed: ()async {
...
if (isPhoneCheckSupported){
final PhoneCheck? phoneCheckResponse =
await createPhoneCheck(phoneNumber);
if (phoneCheckResponse == null) {
setState(() {
loading = false;
});
return errorHandler(context, 'Something went wrong.',
'Phone number not supported');
}
} else {
...
}
},

In the example above, when the user submits the TextField, it first checks whether PhoneCheck is supported. If PhoneCheck is supported, then another check is made to see whether the value of phoneCheckResponse is null, which will call the errorHandler function.

Open the Check URL

When the application creates a PhoneCheck, the response will contain a check_url, which the application uses to make a GET request to the mobile network operator for the check to be successful.

The tru.ID Flutter SDK uses native functionality to force the network request through a cellular connection rather than WiFi. To make this request in the onPressed function within the TextButton widget, update your onPressed functionality as shown below:

onPressed: () async {
...
if (isPhoneCheckSupported) {
...
// open check URL
String? result =
await sdk.check(phoneCheckResponse.checkUrl);
if (result == null) {
setState(() {
loading = false;
});
errorHandler(context, "Something went wrong.",
"Failed to open Check URL.");
}
} else {
},

The code above first opens the check URL; it then checks whether a result was received, which will indicate whether the check was successful or not.

Get the PhoneCheck result

The final implementation in this tutorial is to retrieve the PhoneCheck result. First, you'll create a new data class representing the properties expected in the PhoneCheck response.

At the bottom of lib/models.dart, add the following PhoneCheckResult class:

PhoneCheckResult phoneCheckResultFromJSON(String jsonString) =>
PhoneCheckResult.fromJson(jsonDecode(jsonString));
class PhoneCheckResult {
bool match;
PhoneCheckResult({required this.match});
factory PhoneCheckResult.fromJson(Map<String, dynamic> json) =>
PhoneCheckResult(match: json["match"]);
}

The application now needs to request GET the PhoneCheck, passing in the check_id as a parameter. To make this request and use the newly created data class, open lib/registration.dart and underneath the createPhoneCheck function, add the following getPhoneCheck function:

Future<PhoneCheckResult?> getPhoneCheck(String checkId) async {
final response =
await http.get(Uri.parse('$baseURL/phone-check?check_id=$checkId'));
if (response.statusCode != 200) {
return null;
}
final String data = response.body;
return phoneCheckResultFromJSON(data);
}

Update the if (isPhoneCheckSupported) {} block of the onPressed handler of the TextButton widget.

onPressed: ()async {
...
if (isPhoneCheckSupported) {
final PhoneCheck? phoneCheckResponse =
await createPhoneCheck(phoneNumber);
if (phoneCheckResponse == null) {
setState(() {
loading = false;
});
return errorHandler(context, 'Something went wrong.',
'Phone number not supported');
}
// open check URL
String? result =
await sdk.check(phoneCheckResponse.checkUrl);
if (result == null) {
setState(() {
loading = false;
});
return errorHandler(context, "Something went wrong.",
"Failed to open Check URL.");
}
final PhoneCheckResult? phoneCheckResult =
await getPhoneCheck(phoneCheckResponse.checkId);
if (phoneCheckResult == null) {
// return dialog
setState(() {
loading = false;
});
return errorHandler(context, 'Something Went Wrong.',
'Please contact support.');
}
if (phoneCheckResult.match) {
// proceed with Supabase Auth
GotrueSessionResponse result =
await supabase.auth.signUp(email, password);
if (result.error != null) {
setState(() {
loading = false;
});
return errorHandler(context,
"Something went wrong.", result.error!.message);
}
if (result.data?.user != null) {
setState(() {
loading = false;
});
return successHandler(context);
}
} else {
setState(() {
loading = false;
});
return errorHandler(
context,
'Registration Unsuccessful.',
'Unable to verify your phone number.'
);
}
}
},

Here, you call the getPhoneCheck function, and if phoneCheckResult is null, it calls the errorHandler. The application then checks whether the check has a match. If there is a match, we then proceed with the signup process using Supabase.

The image below shows an example of the application if there is no match in the PhoneCheck.

A tru.ID mobile app screen with a modal showing an overlay with the title 'Registration Unsuccessful' and a message saying 'Unable to verify your phone number.'

Wrapping up

That's it! With everything in place, you now have a seamless signup onboarding flow with minimal UX friction, resulting in reduced user drop-offs.

Resources

Made withacross the 🌍
© 2021 4Auth Limited. All rights reserved. tru.ID is the trading name of 4Auth Limited.