How to Detect a SIM Swap With PHP Before Sending an SMS OTP

November 10, 2022
Written by
Reviewed by

How to Detect a SIM Swap With PHP Before Sending an SMS OTP

One-time passwords (OTPs) sent via SMS are often a great solution for helping people better protect access to their online accounts. However, it's not a perfect solution, because phones are vulnerable to SIM swap attacks.

A SIM swap attack happens when a malicious actor convinces someone working for a cellular phone carrier to link a user's phone number to a new SIM under their control. This SIM can be a physical or a newer embedded SIM or eSIM.

After the phone number is relinked, the genuine user loses control of their number. The malicious user is then able to receive all calls and SMS sent to the phone number, allowing them to receive any OTPs delivered via SMS.

Twilio now offers SIM swap detection through the Lookup API, which reduces the possibility of SIM swap attacks.

In this tutorial you will learn what SIM swap detection is and how it lets you detect attacks using the new SIM Swap package in Twilio's Lookup API.

Prerequisites

To follow along with this tutorial you will need the following:

What is SIM swap detection?

Quoting the documentation:

Lookup SIM Swap (SIM swap detection) provides real-time authoritative data, directly sourced from mobile network operators, telling you if the SIM linked to a mobile phone number has recently changed…It can help you assess the potential risk that a mobile phone number, and the associated user's account, has been potentially compromised.

SIM swap detection queries real-time carrier data. Given that, Twilio requires carrier approvals before it can provision access to that information. Contact Twilio today to start the onboarding process.

What is the SIM Swap package?

When requested, Twilio's Lookup API will return an additional element named sim_swap, which you can see highlighted in the example response below.

hl_lines="9,10,11,12,13,14,15,16,17,18,19"
{
  "calling_country_code": "44",
  "country_code": "GB",
  "phone_number": "+447772000001",
  "national_format": "07772 000001",
  "valid": true,
  "validation_errors": null,
  "caller_name": null,
  "sim_swap": {
    "last_sim_swap": {
      "last_sim_swap_date": "2020-04-27T10:18:50Z",
      "swapped_period": "PT15282H33M44S",
      "swapped_in_period": true
    },
    "carrier_name": "Vodafone UK",
    "mobile_country_code": "276",
    "mobile_network_code": "02",
    "error_code": null
  },
  "call_forwarding": null,
  "live_activity": null,
  "line_type_intelligence": null,
  "url": "https://lookups.twilio.com/v2/PhoneNumbers/+447772000001"
}

Here's are the details of the key fields:

  • last_sim_swap_date: This is an ISO-8601 date and timestamp showing when the SIM was last changed for the specified mobile phone number. Be aware that this is only returned for certain countries.
  • swapped_period: This is an ISO-8601 duration representing a trailing time period, during which swapped_in_period indicates if the SIM was changed for the specified mobile phone number.
  • swapped_in_period: This is a boolean, indicating whether the SIM was changed for the specified mobile phone number during the trailing duration (swapped_period).
  • error_code: This contains the error code associated with the request, if any was returned.

You can find out more about all returned fields in the documentation.

Create the project directory structure

As always, start off by creating the project's directory structure and switch into it, by running the commands below.

mkdir -p lookup-sim-swap/src/SimSwap/Hydrator/Strategy
cd lookup-sim-swap

If you're using Microsoft Windows, don't use the -p option.

Set the required environment variables

You next need to set three environment variables, to interact with Twilio's Lookup API. These are your Twilio Account SID, Auth Token, and phone number.

To do this, create a new file named .env in the top-level directory of the project, and paste the configuration below.

# Twilio credentials
TWILIO_ACCOUNT_SID="xxxxxxxxxxxx"
TWILIO_AUTH_TOKEN="xxxxxxxxxxxx"

# Your phone number
PHONE_NUMBER="xxxxxxxxxxxx"

After that, replace the PHONE_NUMBER's placeholder with the phone number that you want to check.

Set your Twilio credentials

Account Info section of the Twilio Console

Next, from the Twilio Console's Dashboard, copy your Account SID and Auth Token and paste them in place of the respective placeholder values (TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN) in .env.

Install the dependencies

With the project's directory structure created, it's time to install the three required dependencies; these are:

laminas-hydratorlaminas-hydrator simplifies hydrating objects (or populating an object from a set of data) and extracting data from them.
PHP DotenvPHP Dotenv populates PHP's `$_SERVER` and `$_ENV` superglobals from a configuration file. This encourages keeping sensitive configuration details out of code and version control.
Twilio’s PHP Helper LibraryThis package reduces the effort required to interact with Twilio's APIs.

To install the packages, in the top-level directory of the project, run the command below.

composer require --with-all-dependencies \
    laminas/laminas-hydrator \
    twilio/sdk \
    vlucas/phpdotenv

If you're using cmd.exe on Microsoft Windows, replace the backslash character () with a caret (^).

Then, open composer.json in your preferred editor or IDE and add the following after the require property.

"autoload": {
        "psr-4": {
        "SimSwap\\": "src/SimSwap"
    }
}

Write the PHP code

Now, it's time to write the code!

LastSimSwap

In the src/SimSwap directory, create a new file named LastSimSwap.php, and in it, paste the code below.

<?php

namespace SimSwap;

final class LastSimSwap
{
    private ?\DateTimeImmutable $lastSimSwapDate = null;
    private ?string $swappedPeriod = null;
    private ?bool $swappedInPeriod = false;

    public function getLastSimSwapDate(): ?\DateTimeImmutable
    {
        return $this->lastSimSwapDate;
    }

    public function getSwappedPeriod(): ?string
    {
        return $this->swappedPeriod;
    }

    public function wasSwappedWithinSwapPeriod(): bool
    {
        return $this->swappedInPeriod;
    }

    public function swappedPeriodToString(): string
    {
        $properties = [
            'y' => 'year',
            'm' => 'month',
            'd' => 'day',
            'h' => 'hour',
            'i' => 'minute',
            's' => 'second'
        ];
        $last = array_key_last($properties);
        $result = '';
        foreach ($properties as $property => $propertyFull) {
            if ($this->swappedPeriod->$property !== 0) {
                $pattern = ($property !== $last) ? "%s %s, " : "and %s %s";
                $result .= sprintf(
                    $pattern,
                    number_format($this->swappedPeriod->$property),
                    $this->swappedPeriod->$property === 1
                        ? $propertyFull
                        : $propertyFull . "s"
                );
            }
        }

        return $result;
}

This class will model the last_sim_swap element in the response from the Lookup API.

The key item to focus on is the swappedPeriodToString() function. This function returns a human-readable representation of the ISO 8601 duration contained in swappedPeriod.

It does this by iterating over swappedPeriod's date and time properties which have a value greater than zero, to build a string from that information. For example, if it was set to P1DT2H29M then 1 day, 2 hours, and 29 minutes would be returned.

SimSwap

Next, create another new file, in src/SimSwap, named SimSwap.php, and in it paste the code below.

<?php

declare(strict_types=1);

namespace SimSwap;

final class SimSwap
{
    private ?LastSimSwap $lastSimSwap = null;
    private ?string $mobileNetworkCode = null;
    private ?int $mobileCountryCode = null;
    private ?string $carrierName = null;
    private ?int $errorCode = null;

    public function getLastSimSwap(): ?LastSimSwap
    {
        return $this->lastSimSwap;
    }

    public function getMobileNetworkCode(): ?string
    {
        return $this->mobileNetworkCode;
    }

    public function getMobileCountryCode(): ?int
    {
        return $this->mobileCountryCode;
    }

    public function getCarrierName(): ?string
    {
        return $this->carrierName;
    }

    public function getErrorCode(): ?int
    {
        return $this->errorCode;
    }

    public function getErrorReason(): string
    {
        return match($this->errorCode) {
            60606 => 'Package not enabled',
            60607 => 'Provider not found. API does not have coverage for this specific country',
            default => 'Unknown error code',
        };
    }
}

This class models the sim_swap element in the response and uses the LastSimSwap class, via composition, to store the last_sim_swap information. It has a getErrorReason() method to simplify determining the cause of an error, should one be returned.

DateIntervalStrategy

Next, in src/SimSwap/Hydrator/Strategy, create a new file named DateIntervalStrategy.php, and in that file, paste the following code.

<?php

declare(strict_types=1);

namespace SimSwap\Hydrator\Strategy;

use DateInterval;
use Laminas\Hydrator\Strategy\StrategyInterface;

class DateIntervalStrategy implements StrategyInterface
{
    public function extract($value, ?object $object = null)
    {
        return $value;
    }

    public function hydrate($value, ?array $data = null)
    {
        if ($value instanceof DateInterval) {
            return $value;
        }

        if (! is_string($value)) {
            throw new \InvalidArgumentException(sprintf(
                'Unable to hydrate. Expected null, string, or DateInterval; %s was given.',
                is_object($value) ? $value::class : gettype($value)
            ));
        }

        return new DateInterval($value);
    }
}

This class will hydrate an object from an ISO 8601 duration using the hydrate() method. If the value supplied to the function ($value) is already a DateInterval instance, then the value is returned. If $value is not a string, then an InvalidArgumentException is thrown. Finally, if $value is a string, then a DateInterval object is instantiated with it and returned.  

Two things are worth noting:

  • Firstly, the code is not that defensive as it doesn't handle if a DateInterval object could not be instantiated from the supplied value.
  • Secondly, the extract method was left deliberately short. It's not used in the code, but is required by StrategyInterface. How would you implement it?

SimSwapFactory

Then, create a third new file in src/SimSwap, named SimSwapFactory.php, and in it paste the code below.

<?php

declare(strict_types=1);

namespace SimSwap;

use Laminas\Hydrator\NamingStrategy\UnderscoreNamingStrategy;
use Laminas\Hydrator\ReflectionHydrator;
use Laminas\Hydrator\Strategy\DateTimeFormatterStrategy;
use Laminas\Hydrator\Strategy\DateTimeImmutableFormatterStrategy;
use Laminas\Hydrator\Strategy\HydratorStrategy;
use Laminas\Hydrator\Strategy\ScalarTypeStrategy;
use SimSwap\Hydrator\Strategy\DateIntervalStrategy;

final class SimSwapFactory
{
    public static function factory(array $data): SimSwap
    {
        $lastSimSwapHydrator = new ReflectionHydrator();
        $lastSimSwapHydrator->setNamingStrategy(
            new UnderscoreNamingStrategy()
        );
        $lastSimSwapHydrator->addStrategy(
            'lastSimSwapDate',
            new DateTimeImmutableFormatterStrategy(
                new DateTimeFormatterStrategy(\DateTimeInterface::ATOM)
            )
        );
        $lastSimSwapHydrator->addStrategy(
            'swappedPeriod', 
            new DateIntervalStrategy()
        );
        $lastSimSwapHydrator->addStrategy(
            'swappedInPeriod', 
            ScalarTypeStrategy::createToBoolean()
        );

        $simSwapHydrator = new ReflectionHydrator();
        $simSwapHydrator->setNamingStrategy(
            new UnderscoreNamingStrategy()
        );
        $simSwapHydrator->addStrategy(
            'mobileNetworkCode', 
            ScalarTypeStrategy::createToString()
        );
        $simSwapHydrator->addStrategy(
            'mobileCountryCode', 
            ScalarTypeStrategy::createToString()
        );
        $simSwapHydrator->addStrategy(
            'carrierName', 
            ScalarTypeStrategy::createToString()
        );
        $simSwapHydrator->addStrategy(
            'errorCode', 
            ScalarTypeStrategy::createToInt()
        );
        $simSwapHydrator->addStrategy(
            'lastSimSwap',
            new HydratorStrategy(
                $lastSimSwapHydrator, 
                LastSimSwap::class
                )
        );

        return $simSwapHydrator->hydrate($data, new SimSwap());
    }
}

Visualisation of hydrating a SimSwap object from the JSON response from the Twilio API

This class defines only one method, factory(). As the image above attempts to show, it hydrates a new SimSwap object from the sim_swap element of the Lookup API's response.

I appreciate that there is a bit going on here – especially if you're new to hydration – and laminas-hydrator in particular. However, in short, the intent of using the package is to avoid writing the hydration code manually. There's no sense in doing that if there is a perfectly good package already available. Right?

factory() starts off by hydrating a LastSimSwap object, with the data from sim_swap's last_sim_swap element. It does this with a ReflectionHydrator ($lastSimSwapHydrator).

As the name suggests, ReflectionHydrator uses PHP's Reflection API to introspect LastSimSwap and match its properties to the appropriate keys  in the provided data array.

While this reduces the code required to hydrate LastSimSwap, as the data keys and property names differ ReflectionHydrator can't fully determine that LastSimSwap::lastSimSwapDate should be initialised with the value of the last_sim_swap_date. It also can't auto-determine that it has to initialise LastSimSwap::lastSimSwapDate as a DateTimeImmutable instance.

So, a series of Strategies will be employed to help the hydrator make properly informed decisions.

Strategy #1 - Match the data keys to the object's properties

UnderscoreNamingStrategy is used to map the keys in the data array to the properties of the class. It does this by converting each data key from snake case to camel case and assigning the key's value to that property, if available. For example, lastSimSwapDate would be hydrated from the value of last_sim_swap_date.

Strategy #2 - Properly initialise the object's properties

Then, it uses the DateTimeFormatterStrategy and DateTimeImmutableFormatterStrategy to initialise lastSimSwapDate as a DateTimeImmutable object from last_sim_swap_date's value.

After that, it uses DateIntervalStrategy to initialise swappedPeriod as a DateInterval object based on swapped_period's value, and ScalarTypeStrategy::createToBoolean() to initialise swappedInPeriod as a boolean representation of swapped_in_period's value.

After that, another ReflectionHydrator is initialised to hydrate a SimSwap object. It works in almost the same way as the first hydrator. The only additional item is that it uses $lastSimSwapHydrator to hydrate the lastSimSwap property.

index.php

Now, there's one final file to create, index.php. Create this file in the project's top-level directory and paste the code below into it.

<?php

declare(strict_types=1);

require_once './vendor/autoload.php';

use SimSwap\SimSwapFactory;
use Twilio\Rest\Client;

$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();

$twilio = new Client(
    $_SERVER['TWILIO_ACCOUNT_SID'], 
    $_SERVER['TWILIO_AUTH_TOKEN']
);

$results = $twilio
    ->lookups
    ->v2
    ->phoneNumbers($_SERVER['PHONE_NUMBER'])
    ->fetch(["fields" => "sim_swap"]);

$simSwap = SimSwapFactory::factory($results->simSwap);

$lastSimSwap = $simSwap->getLastSimSwap();
if ($lastSimSwap === null) {
    echo "No SIM swap data is available for that number";
    exit(0);
};

if ($lastSimSwap->getLastSimSwapDate() instanceof DateTimeImmutable) {
    printf(
        "The SIM was last swapped on %s.",
        $lastSimSwap->getLastSimSwapDate()->format('l, jS M, Y')
    );
} else {
    if ($lastSimSwap->wasSwappedWithinSwapPeriod()) {
        printf(
            "The SIM was swapped within the SIM swap period of %s.",
            $lastSimSwap->swappedPeriodToString()
       );
    } else {
        echo "The SIM was not swapped within the SIM swap period";
    }
}

Similar to my other tutorials, this file uses PHP Dotenv to set environment variables from the variables in .env, which you created earlier in the tutorial. After that, it initialises a new Twilio Client object for brokering the interaction with the Lookup API.

Then, it makes a call to the Lookup API, specifying the phone number to query for and that the response should contain, if available, the Sim Swap information. Following that, it passes the sim_swap element of the response to SimSwapFactory::factory() to initialise a SimSwap object.

After that, it finishes up by printing a message to the terminal, based on the SIM swap data returned in the request.

If no SIM swap data is available, the user is told that. If SIM swap data is available, the last SIM swap date is printed out, if it was provided. If the last SIM swap date was not returned, the user is told if the SIM was swapped within the SIM swap period or not.

There are legitimate reasons for SIMs to be swapped

While SIM swaps can be suspicious, there are also legitimate reasons for a SIM to be swapped. For example, you lost your phone or it was stolen; you want to port your number to a different carrier; you bought a new phone. Please bear these in mind.

Given these reasons, it's hard to programmatically determine if a SIM swap is suspicious or not. The SIM swap data should always be used as part of a larger, more holistic determination.

Test the code

To test the code, run the following command.

php index.php

What was written to your terminal?

That's how to detect a SIM swap with PHP before sending an SMS OTP

Now – if you're participating in the Private Beta – you can find out when a phone number was last swapped or if it has been swapped within the swapped period;  allowing for the information returned by the carrier. 

When available, you're in a better position to know if a SIM swap was suspicious. As a result, you can add an additional layer of security to your PHP applications, and greater peace of mind to your users.

Matthew Setter is the PHP Editor in the Twilio Voices team and a PHP and Go developer. He’s also the author of Deploy With Docker Compose and Mezzio Essentials. When he’s not writing PHP code, he’s editing great PHP articles here at Twilio. You can find him at msetter@twilio.com, Twitter, GitHub, and LinkedIn.