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 IDlayr PhoneCheck API.
The IDlayr 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. IDlayr 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:
- A IDlayr Account
- A Supabase Account
- A mobile phone with an active data SIM card
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
npm install -g @tru_id/cli
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
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 IDlayr project within the root directory with the following command:tru projects:create flutter-supabase-auth --project-dir .
flutter-supabase-auth
directory and run the following command to clone the dev-server
:git clone [email protected]:tru-ID/dev-server.git
dev-server
directory, run the following command to create a.env
copy of .env.example
:cp .env.example .env
.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.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-authtokenNGROK_REGION=eu # This is where your ngrok URL will be hosted. If you're using IDlayr's `eu` data residency; leave it as is. Otherwise, you could specify `in` or `us`.
dev-server
directory, run the following two commands:npm install # Installs all third-party dependencies in package.jsonnpm run dev # Starts the server
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:
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.
You'll then get redirected to the dashboard. Once there, click the user icon on the left or the ‘Try Auth’ button.
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:
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:
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:
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.
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 IDlayr PhoneCheck API.
For this, the first step is to make a request to the IDlayr 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#ordart pub add tru_sdk_flutter
Add the import tru.ID (IDlayr) 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.idlayr.com/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#ordart 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 (IDlayr) 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 IDlayr'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 AuthGotrueSessionResponse 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.
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.