SIM Card Based Mobile Authentication with iOS

In this tutorial, we will walk you through how to build a simple iOS application which integrates tru.ID SubscriberCheck as your application's authentication workflow.
Murat YakiciMobile Technical Team Lead
Last updated: 16 April 2021
tutorial cover image

tru.ID SubscriberCheck offers both mobile phone number verification and SIM swap detection. SubscriberCheck achieves this by combining the workflows of PhoneCheck, which confirms the ownership of a mobile phone number by verifying the possession of an active SIM card with the same number, with SIMCheck, which provides information on when a SIM card associated with a mobile phone number was last changed. SubscriberCheck can be used when augmenting existing 2FA or anti-fraud workflows.

phone device wrapper

If you'd prefer to go directly to the completed code, it's in the sim-card-auth-ios Github repository.

Before you begin

For development you'll need:

With the above in place, let's dive straight into adding SubscriberCheck functionality to your iOS applications.

Set-up the tru.ID CLI and Run a Development Server

tru.ID provides a tru.ID CLI to quickly set up a development environment and provide a development server on your machine.

The development server acts as a proxy in between your mobile app and the tru.ID servers. It also opens up a local tunnel that makes the server publicly accessible over the Internet, so your mobile device can still connect to it when on cellular data. This architecture means you can focus on the mobile application development during this tutorial.

Your production architecture should mirror this client/server architecture (but may not be the same API), with your servers acting as a secure proxy to the tru.ID APIs. See the SubscriberCheck Workflow Integration guide for more details.

With the background information out of the way, sign up for a tru.ID account account. The account comes with some free credits, which you can use during the development and to test your app against the production environment when it is ready.

Next, install the tru.ID CLI:

npm install -g @tru_id/cli

Run the tru setup:credentials command with the credentials you can copy from the tru.ID console:

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

Install the CLI development server plugin:

tru plugins:install @tru_id/cli-plugin-dev-server

Create a new tru.ID project:

tru projects:create iOSAuthDemoServer

This saves a tru.json tru.ID project configuration to ./iosauthdemoserver/tru.json.

Run the development server by pointing it to the newly created project directory and configuration.

tru server -t --project-dir ./iosauthdemoserver

Check that the URL that is shown in the terminal is accessible in your web browser. The URL is in the format https://{subdomain} This is the public accessible URL to your local development server.

Create a New iOS Project

With the tru.ID account created and the development server up and running, we can make a start with the application. You can skip this step if you already have an iOS project. Otherwise;

  • Launch your Xcode
  • File -> New -> Project
  • In the "Choose a template for your new project" modal, select App and click Next
  • Set "sim-card-auth-ios" as the Product Name, however, you can use whatever the name of your project is
  • Select your Team, and make sure to assign an organization identifier using a reverse domain notation
  • Keep it simple, and use a Storyboard, UIKit App Delegate and Swift as the development language
  • Uncheck Use Code Data if it is checked, and click Next
  • Select the folder you want to store your project in and click Next

As you see, it is a pretty simple project with a single ViewControlller. At this point, you do not need to worry about the AppDelegate or SceneDelegate. This is enough to demonstrate SubscriberCheck.

If you already have Xcode and have added your developer account (Xcode->Preferences->Accounts), Xcode takes care of generating necessary certificates and provisioning profiles in order to install the app on the device.

Build the User Interface

Navigate to the Main.storyboard. You need to add a few UI components to receive input from the user, and provide feedback:

  • Add a UILabel to the View Controller's view as a title with a text "Verification"
  • A UIActivityIndicator (Large) to show/hide progress when you perform a SubscriberCheck
  • A UILabel with a text "Phone number" to indicate what the next text field is for
  • A UITextField so that the user can enter their phone number
  • A UIButton to trigger the SubscriberCheck request
  • A UImageView to show whether SubscriberCheck is successful or not

All UI components are "Horizontally" aligned in the container using constraints. You should also need to define constraints to anchor the components as well.

Start with the top "Verification" UILabel and specify the alignment between the Safe Area and the label. Do the same for all other components, define a constraint for the Top space, and where necessary, add additional constraints for width and height.

The view layout should look like this:

Main Storyboard

There are a few configuration options you should add for these UI components.

  • Phone number UITextField: Select the text field, and on the Attributes Inspector, scroll to Text Input Traits and change the Content Type to Telephone Number. Also, change the Keyboard Type to Phone Pad.
  • UIActivityIndicator: Select the activity indicator, and on the Attributes Inspector check Hides When Stopped
  • UIImageView: Select the UIImageView, and on the Attributes Inspector, scroll to Drawing, and check Hidden

Now, you need to define Outlets in the ViewController so that you can control the UI state. Let's select ViewController in Xcode, and then by using the select Main.storyboard file. Both ViewController.swift and Main.storyboard should be opened side by side.

Select the UIActivityIndicator you inserted to the storyboard, and with key pressed, drag a connection from the storyboard to the ViewController.swift. Xcode indicates possible places in the Editor where you can create an Outlet.

When you are happy, release the keys and mouse/trackpad. You will be prompted to enter a name for the variable; type busyActivityIndicator.

You need to connect the UITextField, UIButton and the UIImageView as well. Let's perform the above steps for these as well respectively, and name them as follows:

  • phoneNumberTextField
  • nextButton
  • checkResults


This allows you to retrieve the phone number entered by the user, and control the state to provide feedback. You now have one last task to do related to the storyboard.

Let's insert an action. When a user taps on the Next button, you want the ViewController to know that the user wants to initiate the SubscriberCheck. So select the Next button, and with your key pressed drag a connection from the storyboard to the ViewController.swift. Xcode indicates possible places where you can create an IBAction. When you are happy, release the keys and mouse/trackpad. You will be prompted to enter a name for the method: type next and Xcode will insert the method with a IBAction annotation.

It is time to write some code to manage the UI state. The first method you are going to add is controls(enabled: Bool). This method helps us show or hide the checkResults, and busyActivityIndicator. You should also disable the phoneNumberTextField when the SubscriberCheck flow is in progress.

// MARK: UI Controls Configure || Enable/Disable
private func controls(enabled: Bool) {
if enabled {
} else {
phoneNumberTextField.isEnabled = enabled
nextButton.isEnabled = enabled
checkResults.isHidden = !enabled
private func configureCheckResults(match: Bool, noSimChange: Bool) {
if match {
let image = UIImage(systemName: "person.fill.checkmark")
self.checkResults.image = image?.withRenderingMode(.alwaysTemplate)
self.checkResults.tintColor = .green
} else {
let image = UIImage(systemName: "person.fill.xmark")
self.checkResults.image = image?.withRenderingMode(.alwaysTemplate)
self.checkResults.tintColor = .red

You will use these methods later in the next(_ sender: Any) that is triggered by the user tapping the Next button.

Models, Structs & Protocols

Now is the time to create a new group called service in the Project Navigator. In here you'll implement Model layer classes, structs, protocols and enums. Note that none of the files in this group should be importing UIKit.

Create a Swift file in the service group called SessionEndpoint.swift. In this file, we define a protocol called Endpoint and NetworkError of type Enum and a class which implements the protocol. Let's define the protocol Endpoint and NetworkError enum as in the following in this file.

import Foundation
protocol Endpoint {
var baseURL: String { get }
func makeRequest<U: Decodable>(urlRequest: URLRequest,
handler: @escaping (Result<U, NetworkError>) -> Void)
func createURLRequest(method: String,
url: URL,
payload:[String : String]?) -> URLRequest
enum NetworkError: Error {
case invalidURL
case connectionFailed(String)
case httpNotOK
case noData

The purpose of the Endpoint protocol is to hide implementation details from the client of this protocol. It has two methods and a variable baseURL. It represents one REST API endpoint. We'll come back to this in more detail later.

Next, define our model object which holds SubscriberCheck results.

Create a Swift file called SubscriberCheck.swift in the service, and implement a struct with the same name as below:

import Foundation
// Response model based on
struct SubscriberCheck: Codable {
let check_id: String?
let check_url: String?
let status: SubscriberCheckStatus?
let match: Bool?
let no_sim_change: Bool?
enum SubscriberCheckStatus: String, Codable {
case ERROR

tru-ID REST API documentation provides us the basis for this struct. In production, your architecture and servers may expose a different REST response model. Also important to note that SubscriberCheck implements the Codable protocol as this will help to decode the json response to the SubscriberCheck easily.

Next, define a protocol which will help the View layer to talk to the Model layer implementation.

The View layer of our application is concerned about what the user is going to request. At this layer, you shouldn't be concerned about "how" it is going to be done. Since it is all about SubscriberCheck, simple Subscriber protocol which defines a function to receive a phone number and provides a closure for the SubscriberCheck results should be sufficient.

Create a Swift file in the service group called SubscriberCheckService.swift. In this file, define the Subscriber protocol as the following:

protocol Subscriber {
func check(phoneNumber: String,
handler: @escaping (Result<SubscriberCheck, NetworkError>) -> Void)

Create a class called SubscriberCheckService in the existing SubscriberCheckService.swift file which implements the Subscriber protocol.

final class SubscriberCheckService: Subscriber {
let path = "/subscriber-check"
public func check(phoneNumber: String, handler: @escaping (Result<SubscriberCheck, NetworkError>) -> Void) {
//Implement the workflow

A variable called path is defined to hold the path for the development server endpoint to perform the necessary calls. The path is /subscriber-check for the local development server. The check(phoneNumber: ...) method will contain the logic for the workflow.

Implement the User Action

At this point, the UI is almost ready to execute the SubscriberCheck workflow. Let's first define a variable of Subscriber type in our ViewController and then implement the next(_ sender: Any) IBAction we previously created.

Add the following code to the ViewController.

var subscriberService: Subscriber!
override func viewDidLoad() {
subscriberService = SubscriberCheckService()

This initialises subscriberService with a concrete implementation SubscriberCheckService which you defined in the previous section. SubscriberCheckService knows how to execute the workflow and all ViewController needs to do is to call check(phoneNumber: String, ..) method and control the UI state. It is time to implement the next(_ sender: Any). It looks as follows:

@IBAction func next(_ sender: Any) {
guard let phoneNumber = phoneNumberTextField.text else {
if !phoneNumber.isEmpty {
// Ideally you should validated phone number against e164 spec
// Without leading + or 0's
// For example: {country_code}{number}, 447940448591
// Remove double 00's
var strippedPhoneNumber = phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines)
if let range = strippedPhoneNumber.range(of: "00") {
strippedPhoneNumber.replaceSubrange(range, with: "")
controls(enabled: false)
subscriberService.check(phoneNumber: strippedPhoneNumber) { [weak self] (checkResult) in
DispatchQueue.main.async {
switch checkResult {
case .success(let subscriberCheck):
self?.configureCheckResults(match: subscriberCheck.match ?? false, noSimChange: subscriberCheck.no_sim_change ?? false)
case .failure(let error):

The implementation of the next method first checks whether there is text in the phoneNumberTextField and whether it is empty or not.

Note that in a production code, you should validate the phone number against the E.164 specification. We are keeping it simple for the purposes of this tutorial by only removing 00 from the beginning of the phone number (if it exists) and trimming.

The second step is to disable parts of the user interface, show the activity indicator and let it spin when the user taps the Next button. The third step is to call the check(phoneNumber:) method of the subscriberService. The handler will provide a checkResult which is of type Result<SubscriberCheck,NetworkError>. Note that this closure will not be called in the main queue, therefore you need to wrap any code which accesses UIKit in a DispatchQueue.main.async.

If the workflow executes successfully then you can access the model details and reconfigure the UI. Note that the .success case doesn't necessarily mean that validation is successful, it is simply an indication that workflow executed without encountering any network errors.

In order to understand if you validated the phone number you need to inspect the .success payload which is of type SubscriberCheck. The following line will ensure that validation results are reflected in the UI:

self?.configureCheckResults(match: subscriberCheck.match ?? false, noSimChange: subscriberCheck.no_sim_change ?? false)

In any case, you restore the UI controls back to their original state with the following code so that the user can re-execute the workflow, if needed:


Setup the Network and Define Endpoints

Your app may have its own ways for defining and access external service URLs, and these endpoints may be stored in configuration files such as a plist, or in your Swift code. In this tutorial, you store the development and production base URLs in a plist called TruIdService-Info.plist. These server endpoints proxy some of the requests through to the tru.ID API.

Create a group called util in the Project Navigator. Next, create the Property List:

  • File -> New -> File
  • Select Property List in the dialog
  • Click Next
  • Select where you want to store the file (default selected folder should be fine)
  • Type TruIdService-Info as the file name
  • Click Create

You should see this file created in the util group. Now, let's add two keys to this plist file; one for the development endpoints and one for the production endpoints. The values should be String type.



You must ensure to assign the correct value to the development_server_base_url in order to complete this tutorial and successfully run the app on your device. This value is the one you are provided from the Terminal when you set up and ran your development server at the beginning of this tutorial (e.g. https://{subdomain} For production setup, you should implement your own backend and add this URL here in the production_server_base_url.

In order to read the plist, create a struct called AppConfiguration, which deals with loading the correct endpoint so you do not have to worry when you are implementing the use cases.

Project Navigation

import Foundation
struct AppConfiguration {
let defaultConfigurationName = "TruIdService-Info"
var configuration: [String : Any]?
mutating func loadServerConfiguration() {
if let path = Bundle.main.path(forResource: defaultConfigurationName, ofType: "plist"),
let xml = FileManager.default.contents(atPath: path)
self.configuration = (try? PropertyListSerialization.propertyList(from: xml,
options: .mutableContainersAndLeaves,
format: nil)) as? [String : Any]
func baseURL() -> String? {
var key = "production_server_base_url"
key = "development_server_base_url"
return configuration?[key] as? String

The AppConfiguration struct simply reaches into the main bundle and searches for a plist called TruIdService-Info. If found, it reads the plist as a dictionary and binds that to the configuration variable. This URL is provided to the clients of the struct via the baseURL() -> String? method.

Now, implement a class called SessionEndpoint within the existing service/SessionEndpoint.swift file. This is our implementation of simple network requests using URLSession. You can implement this protocol using URLSession, or with Alamofire. For the purposes of this tutorial, let's keep it simple and implement the protocol using URLSession.

final class SessionEndpoint: Endpoint {
let baseURL: String
private let session: URLSession
init() {
var configuration = AppConfiguration()
baseURL = configuration.baseURL()!//Fail early so that you know there is something wrong
session = SessionEndpoint.createSession()
private static func createSession() -> URLSession {
let configuration = URLSessionConfiguration.ephemeral //you do not want OS to cache or persist
configuration.allowsCellularAccess = true
configuration.waitsForConnectivity = true
configuration.networkServiceType = .responsiveData
return URLSession(configuration: configuration)
// MARK: Protocol Implementation
func makeRequest<U: Decodable>(urlRequest: URLRequest,
handler: @escaping (Result<U, NetworkError>) -> Void) {
let task = session.dataTask(with: urlRequest) { (data, response, error) in
if let error = error {
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
handler(.failure(.connectionFailed("HTTP not OK")))
guard let data = data else {
print("Response: \(String(describing: String(data: data, encoding: .utf8)))")
if let dataModel = try? JSONDecoder().decode(U.self, from: data) {
func createURLRequest(method: String, url: URL, payload:[String : String]?) -> URLRequest {
var urlRequest = URLRequest(url: url)
urlRequest.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
urlRequest.httpMethod = method
if let payload = payload {
let jsonData = try! payload, options: .prettyPrinted)
urlRequest.httpBody = jsonData
return urlRequest

The init() method of the class loads a base URL from the AppConfiguration which you defined earlier. It returns a URL for either a development server or a production server, depending on the build scheme. The final line of the init() creates a URLSession using a private static method. Note that the createSession() method creates a session configuration which doesn't cache or persist network related information; it is ephemeral for additional security.

The rest of the file contains the Endpoint protocol implementation. The first method, makeRequest<>.., creates a data task using the URLRequest provided and initiates the call. When the response is received, the method calls the handler closure for success or failure cases. If data exists and there are no error scenarios, it attempts to decode the data to the model type provided.

The Result<> generic type refers to a model object and an Enum which provides error cases.

The createURLRequest(..) method receives three parameters; HTTP method name, the URL and an optional payload if the request is a POST request, for instance. The method returns a URLRequest object, which is then used by the makeRequest<>.. method during the execution of the workflow.

Implement the Workflow

Now that you have defined the user interface and network request/response mechanics, let's bridge the two and implement the business logic. It is time to talk about the SubscriberCheck workflow before you dive more into the coding.

The SubscriberCheck workflow has 3 steps:

  1. Create a SubscriberCheck via the server. This will return a check_id and check_url.
  2. Then request the check_url retrieved in step 1 using the tru.ID iOS SDK
  3. As soon as check_url request returns, retrieve the SubscriberCheck results using the check_id retrieved in step 1

The following sequence diagram shows each step, including the server to tru.ID API interactions:

SubscriberCheck Workflow

Let's implement the workflow in SubscriberCheckService using some helper methods and the classes you created in the previous sections.

First, let's add a variable called endpoint to the SubscriberCheckService and initiate it in the init() method. The SubscriberCheckService class uses a concrete implementation of Endpoint protocol called SessionEndpoint which you defined in the previous sections. This class makes it easier to execute network calls.

final class SubscriberCheckService: Subscriber {
let path = "/subscriber-check"
let endpoint: Endpoint
self.endpoint = SessionEndpoint()

Let's also define three methods, each corresponding to the steps mentioned above. Later, we will stitch the steps of the workflow in check(phoneNumber: ...) method.

The first method is createSubscriberCheck(phoneNumber:...). It makes a POST request to the server to create a SubscriberCheck. This call can be made over any type of network (cellular/wifi). The server should return a SubscriberCheck check_url.

Add the following method to the SubscriberCheckService class:

private func createSubscriberCheck(phoneNumber: String,
handler: @escaping (Result<SubscriberCheck, NetworkError>) -> Void) {
let urlString = endpoint.baseURL + path
guard let url = URL(string: urlString) else {
let phoneNumberDict = ["phone_number" : phoneNumber]
let urlRequest = endpoint.createURLRequest(method: "POST", url: url, payload: phoneNumberDict)
endpoint.makeRequest(urlRequest: urlRequest, handler: handler)

This method receives a phone number, constructs the full URL using the endpoint.baseURL and the SubscriberCheck path which is defined by the server. It creates a payload (just the phone number), and a URLRequest using the endpoint.createURLRequest(..) method. Then the method uses the makeRequest(..) method of the endpoint and passes the urlRequest and the handler.

Our next method is called retrieveSubscriberCheck(..). Add it to the SubscriberCheckService class:

private func retrieveSubscriberCheck(checkId: String,
handler: @escaping (Result<SubscriberCheck, NetworkError>) -> Void) {
let urlString = endpoint.baseURL + path + "/" + checkId
guard let url = URL(string: urlString) else {
let urlRequest = endpoint.createURLRequest(method: "GET", url: url, payload: nil)
endpoint.makeRequest(urlRequest: urlRequest, handler: handler)

This is very similar to the first method you defined, with a few differences. You are now calling the endpoint with an extra checkId parameter, and this time it is a GET call. This method will get the results of the check performed.

The third method we need to create is doing the heavy lifting using the tru.ID SDK. Let's create a method called requestSubscriberCheckURL(..):

private func requestSubscriberCheckURL(subscriberCheckURL: String,
handler: @escaping () -> Void) {

This method requests the check_url which will be returned by the createSubscriberCheck(..). In order to do that you need the tru.Id iOS SDK. Let's add it to our project.

The tru.ID iOS SDK ensures that certain network calls are done on a Cellular network type, which is needed to run SubscriberCheck workflow. You have two options to add the SDK to your project.

Using Swift Package Manager

Xcode integrates well with Github, and you can add Swift Packages very easily. In your Xcode, go to File -> Swift Packages -> Add Package Dependency...

Type, and tap Next.

Xcode will find the package, select the latest version and then tap Finish to add it. Now that the SDK is added, you can import it when you are implementing the workflow.

Using CocoaPods

While we recommend using Swift Package Manager, tru.ID iOS SDK also supports adding your dependencies via CocoaPods. If you are familiar with CocoaPods and prefer using it, all you need to do is to create a Podfile and add the tru.ID pod the following way:

target 'MyApp' do
pod 'tru-sdk-ios'

Make sure to run pod install in your project directory. After the CocoaPods install all necessary pods, and configure your project, don't forget to open the workspace rather than the project file.

Request the check_url

Let's go back to the SubscriberCheckService and import the TruSDK:

import TruSDK

Then use the SDK within requestSubscriberCheckURL(..) to request the check_url:

private func requestSubscriberCheckURL(subscriberCheckURL: String,
handler: @escaping () -> Void) {
let tru = TruSDK()
tru.openCheckUrl(url: subscriberCheckURL) { (something) in

It's that simple.

As discussed, the SDK will ensure that this call will be made over the cellular network. When the openCheckUrl(..) calls the closure, you call the handler as well.

You are yet to implement the SubscriberCheckService check(phoneNumber:...) method, and chain the methods in our check(phoneNumber: String.. method. So, let's do that:

public func check(phoneNumber: String, handler: @escaping (Result<SubscriberCheck, NetworkError>) -> Void) {
createSubscriberCheck(phoneNumber: phoneNumber) { (createResult) in
var checkURL = ""
var checkID = ""
switch createResult {
case .success(let subscriberCheck):
// The server returns the SubscriberCheck results to the device.
checkURL = subscriberCheck.check_url!
checkID = subscriberCheck.check_id!
print("Got the subscriber check URL: \(String(describing: subscriberCheck.check_url)) ")
case .failure(let error):
print("Using the SDK to request check URL over mobile network")
self.requestSubscriberCheckURL(subscriberCheckURL: checkURL) { [weak self] in
guard let self = self else {
print("SDK successfully returned, let's call our server to retrieve check results.")
self.retrieveSubscriberCheck(checkId: checkID) { (checkResult) in
switch checkResult {
case .success(let checkResultModel):
case .failure(let error):

First, you make a call using the createSubscriberCheck(phoneNumber: phoneNumber) ... method. The callback to this method inspects the Result<>. If it is a success, it fetches the checkURL and checkID and stores them in local variables which will be used later.

Within the callback you execute the second step using requestSubscriberCheckURL(...). This method uses the tru.ID iOS SDK and makes a call to check_url. The SDK makes this call over the cellular network, so the user must have a data plan. Behind the scenes, this call will redirect and eventually return OK. All this will be handled by the SDK.

The third step is to make a final request to the server using the checkID that you've got as a result of making the first call. This call will return the SubscriberCheck result; whether the check is successful or not.

Perform your first SubscriberCheck

Now that our code is complete, you can run the application on a real device. Bear in mind that SIM card based authentication is not possible on a Simulator, as you require a SIM Card with an active data connection.

phone device wrapper

You've now integrated SubscriberCheck into an iOS application, enabling you to both verify a phone number and check if the SIM card associated with the phone number has changed recently. All powered by SIM card based mobile authentication.

Where next?


If you have any questions please raise an issue on the GitHub repo.

Mobile Data is Required

Don't forget that the SubscriberCheck validation requires your mobile device to have a data plan from your network operator, and that you should enable mobile data.

Get in touch

If you have any questions, get in touch via

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