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: 23 September 2022
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're also going to need some Project credentials from tru.ID to make API calls. So sign up for a tru.ID account, which comes with some free credit. We've built a CLI for you to manage your tru.ID account, projects, and credentials within your Terminal. To install the tru.ID CLI run the following command:
npm install -g @tru_id/cli
Run tru login <YOUR_IDENTITY_PROVIDER> (this is one of google, github, or microsoft) using the Identity Provider you used when signing up. This command will open a new browser window and ask you to confirm your login. A successful login will show something similar to the below:
Success. Tokens were written to /Users/user/.config/@tru_id/cli/config.json. You can now close the browser
Note: The config.json file contains some information you won't need to modify. This config file includes your Workspace Data Residency (EU, IN, US), your Workspace ID, and token information such as your scope.Create a new tru.ID project within the root directory with the following command:
tru projects:create flutter-supabase-auth --project-dir .
Your project will need a backend server for your mobile application to communicate with. We've created the dev-server for you to get started with this tutorial as quickly as possible. The development server is written in Javascript and is not production ready, so it should only be used to understand the flow of running a Check.In your Terminal, navigate to the flutter-supabase-auth directory and run the following command to clone the dev-server:
git clone git@github.com:tru-ID/dev-server.git
In the dev-server directory, run the following command to create a.env copy of .env.example:
cp .env.example .env
Open this new .env file, update the values of TRU_ID_CLIENT_ID andTRU_ID_CLIENT_SECRET with your client_id and client_secret in yourtru.json file.
You'll need to expose your dev-server to the Internet for your mobile application to access your backend server. For this tutorial, we're using ngrok, which we've included in the dev-server functionality. So to start with, uncomment the following and populate them with your ngrok credentials:
NGROK_ENABLED=true
#NGROK_SUBDOMAIN=a-subdomain # Uncommenting this is optional. It is only available if you have a paid ngrok account.
NGROK_AUTHTOKEN=<YOUR_NGROK_AUTHTOKEN> # This is found in the ngrok dashboard: https://dashboard.ngrok.com/get-started/your-authtoken
NGROK_REGION=eu # This is where your ngrok URL will be hosted. If you're using tru.ID's `eu` data residency; leave it as is. Otherwise, you could specify `in` or `us`.
Run the development server; first, you'll need to install third-party dependencies. In the dev-server directory, run the following two commands:
npm install # Installs all third-party dependencies in package.json
npm run dev # Starts the server
Open up the URL shown in the terminal, which will be in the format in your desktop web browser to check that it is accessible.With the development server setup, we can move on to building the application.

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 page 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', 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 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 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 determining 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 ngrok URL you noted earlier in the tutorial.

final String baseURL = '<YOUR_NGROK_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 called 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 ensures 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();
Map<Object?, Object?> reach = await sdk.openWithDataCellular(
"https://eu.api.tru.id/public/coverage/v0.1/device_ip",
false);
print("-------------REACHABILITY RESULT --------------");
print("isReachable = $reach");
bool isPhoneCheckSupported = true;
if (reach.containsKey("http_status") &&
reach["http_status"] != 200) {
if (reach["http_status"] == 400 ||
reach["http_status"] == 412) {
setState(() {
loading = false;
});
return errorHandler(context, "Something Went Wrong.",
"Mobile Operator not supported, or not a Mobile IP.");
}
} else if (reach.containsKey("http_status") ||
reach["http_status"] == 200) {
Map body =
reach["response_body"] as Map<dynamic, dynamic>;
Coverage coverage = Coverage.fromJson(body);
for (var product in coverage.products!) {
if (product.name == "Phone Check") {
isPhoneCheckSupported = true;
}
}
} else {
isPhoneCheckSupported = true;
}
if (isPhoneCheckSupported) {
} else {
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);
}
}
},

In the code sample above, the Reachability API is called. This API 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';
class PhoneCheck {
final String id;
final String url;
PhoneCheck({required this.id, required this.url});
factory PhoneCheck.fromJson(Map<dynamic, dynamic> json) {
return PhoneCheck(
id: json['check_id'],
url: 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 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;

Locate the if (isPhoneCheckSupported) { line within your onPressed handler, and add the following functionality:

onPressed: ()async {
...
if (isPhoneCheckSupported){
final response = await http.post(
Uri.parse('$baseURL/v0.2/phone-check'),
body: {"phone_number": phoneNumber});
if (response.statusCode != 200) {
setState(() {
loading = false;
});
return errorHandler(context, 'Something went wrong.',
'Unable to create phone check');
}
PhoneCheck checkDetails =
PhoneCheck.fromJson(jsonDecode(response.body));
} else {
...
}
},

In the example above, when the user submits the TextField, it first checks whether PhoneCheck is supported. If PhoneCheck is supported, 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) {
...
Map result =
await sdk.openWithDataCellular(checkDetails.url, false);
print("openWithDataCellular Results -> $result");
if (result.containsKey("error")) {
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.

Complete the PhoneCheck

This section covers completing the PhoneCheck flow and handling the result. To first complete the flow, you'll need to receive the check_id and code found in the final redirect when opening the check URL. These pieces of information need to be included in a PATCH request to tru.ID's API's, via your backend server. So, using the dev-server's /v0.2/phone-check/exchange-code endpoint.

First, a new data class is required to represent the properties expected in the PhoneCheck response.

At the bottom of lib/models.dart, add the following PhoneCheckResult class containing two new fields, id and match:

class PhoneCheckResult {
final String id;
bool match = false;
PhoneCheckResult(
{required this.id, required this.match});
factory PhoneCheckResult.fromJson(Map<dynamic, dynamic> json) {
return PhoneCheckResult(
id: json['check_id'],
match: json['match'] ?? false,
);
}
}

Back in lib/registration.dart, create a new function within your _RegistrationState class. This function will receive a checkID and code. With these pieces of information it will make a JSON encoded POST request to your backend exchange-code endpoint, and if successful return the response of the PhoneCheck.

Future<PhoneCheckResult> exchangeCode(
String checkID, String code, String? referenceID) async {
var body = jsonEncode(<String, String>{
'code': code,
'check_id': checkID,
'reference_id': (referenceID != null) ? referenceID : ""
});
final response = await http.post(
Uri.parse('$baseURL/v0.2/phone-check/exchange-code'),
body: body,
headers: <String, String>{
'content-type': 'application/json; charset=UTF-8',
},
);
print("response request ${response.request}");
if (response.statusCode == 200) {
PhoneCheckResult exchangeCheckRes =
PhoneCheckResult.fromJson(jsonDecode(response.body));
print("Exchange Check Result $exchangeCheckRes");
if (exchangeCheckRes.match) {
print("✅ successful PhoneCheck match");
} else {
print("❌ failed PhoneCheck match");
}
return exchangeCheckRes;
} else {
throw Exception('Failed to exchange Code');
}
}

Within your registration.dart file, locate the code you recently added, which opens the checkDetails.url. Below this block of code, add the following, which checks the HTTP status in the response is 200, it then proceeds to parse the data in the response and call exchangeCode to submit this information to your backend server.

The result for this POST request will contain information on the status of the PhoneCheck, whether the phone number matches the one associated with that SIM card.

if (result.containsKey("http_status") &&
result["http_status"] == 200) {
Map body = result["response_body"] as Map<dynamic, dynamic>;
if (body["code"] != null) {
CheckSuccessBody successBody =
CheckSuccessBody.fromJson(body);
try {
PhoneCheckResult exchangeResult =
await exchangeCode(successBody.checkId,
successBody.code, successBody.referenceId);
if (exchangeResult.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, "Something went wrong.",
"Unable to login. Please try again later");
}
} catch (error) {
setState(() {
loading = false;
});
return errorHandler(context, "Something went wrong.",
"Unable to login. Please try again later");
}
}
}

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 proceed with the signup process using Supabase.

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

A tru.ID mobile app screen with a modal showing an overlay with the title 'Registration Successful' and a message saying 'Able 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

Download our Developer Console mobile app
Made withacross the 🌍
© 2022 4Auth Limited. All rights reserved. tru.ID is the trading name of 4Auth Limited.