Verifying Twilio API Requests in Symfony 5

November 19, 2020
Written by
Alex Dunne
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Verifying Twilio API requests in Symfony 5

Web applications are powerful tools of communication for everyday life. However, due to their ubiquity, the average user underestimates what it takes to actually build them. As a result, the human interfacing with the app just expects it to work, always.

While building an application is easier in 2020 than years prior, its success is often dependent upon the use and  integration of external services to provide additional functionality.

Companies such as Twilio and Stripe offer these services (called APIs) that enable developers to quickly implement functionality that would otherwise be impractical to build independently. Because APIs tend to focus so much on installation, their “Getting Started” guides often guide the developer past the biggest part of their application outside of testing; security.

In this tutorial, we’ll do the opposite and take a look at security by verifying Twilio API requests in Symfony. To start, we’ll create a basic Symfony application that uses Twilio’s Programmable Messaging API to respond to an SMS message with the current timestamp. We’ll then demonstrate how anyone can exploit the application by sending an HTTP request to the webhook you set up for Twilio. Following this, we’ll look at how Twilio helps you to ensure the request is legitimate and validate the contents of the request. Finally, we’ll look at how we can use Symfony’s Built-in events to automate this validation process for every Twilio request handler.

Let’s get started!

Prerequisites

To complete this tutorial you will need the following:

You will also need an active Twilio phone number with SMS capabilities. If you don't already have a phone number, you can purchase onehere.

Create a New Symfony Application

To begin, we will create a new Symfony project. We'll be using the Symfony binary to generate the project for us, so if you don't already have it installed, you can follow the installation instructions from the Symfony documentation. Once you have the Symfony binary installed, run the following command in a terminal:

$ symfony new --full twilio-request-validation && cd twilio-request-validation

We will also install the Twilio PHP SDK for later use. Run the following command:

$ composer require twilio/sdk

Once the dependencies are installed, start the Symfony server by running the following command:

$ symfony server:start

You should see something similar to:

Symfony web server ready

The “[OK] Web server listening” message confirms that the local server has started and the application can be accessed at http://127.0.0.1:8000.

Your Symfony application will need to be exposed to the internet so that Twilio can communicate to the application using webhooks. This is where ngrok comes in.

In a separate terminal, create a new ngrok tunnel by running the following command:

$ ngrok http 8000

If successful, you should see the ngrok session information as well as a Forwarding URL like so:

ngrok forwarding url

Double check everything is working by visiting the URL displayed in your terminal. Once loaded, you should see the default Symfony landing page.

Symfony developer landing page

Next, create a .env.local file using the following command:

$ touch .env.local

By default, Symfony provides a .gitignore file with .env.local as one of the entries. This file is where we will store our values that differ per environment, including secret API keys such as our Twilio credentials.

When using a reverse proxy (e.g. ngrok) or load balancer (e.g. Cloud Load Balancing) in front of Symfony, certain request-specific information is sent using either the standard Forwarded header or X-Forwarded-* headers. If you don’t tell Symfony to look for these headers, you’ll get incorrect information when checking for HTTPS status, the client’s IP address, the client’s, and the requested hostname. As we’ll see later on, when validating a Twilio web request, it’s imperative to have the correct values otherwise the request will be deemed invalid. Fortunately, Symfony makes this trivial to configure for you.

Open the .env.local file you just created and add the following line:

TRUSTED_PROXIES=127.0.0.1,REMOTE_ADDR

Now when ngrok forwards the request to Symfony, Symfony will know to look for the client information in the Forwarded and X-Forwarded-* headers.

Setting Up Twilio

Before we can start communicating with Twilio, we first need to fetch our Twilio credentials. If you haven't already registered for an account, you can create a new account here. Twilio will also provide you with free credits to test the API.

Once you have logged in, navigate to the dashboard and you will see your Account SID and Auth Token.

Twilio Console

You will also need an active phone number with SMS capabilities. If you don't already have a phone number, you can purchase one here.

We now have all of the data required to communicate with Twilio. Copy your Account SID, Auth Token, and phone number to the .env.local file we created earlier as follows:

# .env.local
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
TWILIO_NUMBER=

Now that you have configured your Twilio credentials to be read, the Twilio client can be configured.

Add the following parameter to your config/services.yaml file:

# config/services.yaml

parameters:
    twilio_number: '%env(TWILIO_NUMBER)%'

Also, append the following to your config/services.yaml file to complete the usage of the Twilio credentials:

# config/services.yaml
twilio.client:
        class: Twilio\Rest\Client
        arguments: ['%env(TWILIO_ACCOUNT_SID)%', '%env(TWILIO_AUTH_TOKEN)%']

Twilio\Rest\Client: '@twilio.client'

Handling Inbound SMS Messages

Navigate to the Twilio phone number details page for the phone number you’re using in this project. Scroll down to the Messaging section within the Configure tab and change the dropdown for A MESSAGE COMES IN to “Webhook”. Within the adjacent text field add the value “http://xyz.ngrok.io/webhooks/twilio/sms/incoming”, ensuring that you change the ngrok.io URL to match the one in your terminal.

Twilio webhook configuration

Now create a route and return a simple TwiML response to verify that the configuration works.

Create a new file in the src/Controller directory titled TwilioWebhookController.php and add the following content:

<?php
// src/Controller/TwilioWebhookController.php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Twilio\Rest\Client;

/**
 * @Route("/webhooks/twilio")
 */
class TwilioWebhookController extends AbstractController
{
    /**
     * @Route("/sms/incoming", name="webhook.twilio.sms_incoming")
     * @param Request $request
     * @param Client $twilioClient
     * @return Response
     * @throws \Twilio\Exceptions\TwilioException
     */
    public function handleIncomingSmsMessage(Request $request, Client $twilioClient)
    {
        $from = $request->request->get('From');
        $now = new \DateTime();
        $body = $now->format('Y-m-d H:i:s');

        $twilioClient->messages->create($from, [
            "body" => $now->format('Y-m-d H:i:s'),
            "from" => $this->getParameter('twilio_number')
        ]);

        return new Response();
    }
}

Try sending an SMS message to your Twilio phone number (with any message) to ensure everything is configured correctly. You should receive a response with a date in the following format “2020-10-28 15:05:00”

Sample response message

Great! Twilio has been configured to send our application an API request whenever it receives an SMS. In response, we return the time the message was received to confirm that the application is set up correctly.

At this point, you may be tempted to end the tutorial and resume whatever you were working on before reading! However, the current setup has a huge flaw. We aren’t checking who the request is from. This means that anyone with the knowledge of the API URL can send your application a request and carry it out. This could be exploited to send unsolicited SMS messages to users and result in burning through your Twilio funds or having your account flagged as SPAM.

To try this out yourself, open an API client like Postman or Insomnia and create a POST request to your ngrok URL. Set the content type to x-www-form-urlencoded and provide a From value. You should receive another SMS message shortly after sending the request.

Postman API request

Validating Inbound SMS Messages

Twilio cryptographically signs every request they send to your application. By doing so, we are able to check if the request and the request’s content has been sent by Twilio or a malicious third party.

Twilio signs all requests to your application with an X-Twilio-Signature HTTP header. This signature is generated by combining the parameters sent in the HTTP request and the URL of the webhook, and then hashing them with your Twilio account token as the secret key. By using your account token as the key, you are able to decrypt the hash in your application. As only you and Twilio have access to the key, malicious third party actors cannot spoof a request. Whilst this isn’t strictly required knowledge as Twilio handles this for you, it’s always useful to know how your tools work.

Start by configuring a Twilio RequestValidator by appending the following to your config/services.yaml:

# config/services.yaml

twilio.request_validator:
    class: Twilio\Security\RequestValidator
    arguments: ['%env(TWILIO_AUTH_TOKEN)%']

Twilio\Security\RequestValidator: '@twilio.request_validator'

Now update your TwilioWebhookController to validate the request like so:

<?php
// src/Controller/TwilioWebhookController.php

// ...
use Twilio\Security\RequestValidator;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
// ...

/**
 * @Route("/webhooks/twilio")
 */
class TwilioWebhookController extends AbstractController
{
    /**
     * @Route("/sms/incoming", name="webhook.twilio.sms_incoming")
     * @param Request $request
     * @param Client $twilioClient
     * @param RequestValidator $requestValidator
     * @return Response
     * @throws \Twilio\Exceptions\TwilioException
     */
    public function handleIncomingSmsMessage(Request $request, Client $twilioClient, RequestValidator $requestValidator)
    {
        $signature = $request->headers->get('X-Twilio-Signature');

        if (is_null($signature)) {
            throw new BadRequestHttpException('X-Twilio-Signature header required');
        }

        $isRequestValid = $requestValidator->validate(
            $signature, 
            $request->getUri(),
            $request->request->all()
        );

        if (!$isRequestValid) {
            throw new BadRequestHttpException('Request is invalid');
        }

        // ...
    }
}

If you send another request using your API client, you will receive an error message about missing the X-Twilio-Signature header. If you then also update your request to include a random X-Twilio-Signature value, you will be informed that the request is not valid!

Finally, send another SMS message to your Twilio number to confirm the application still handles requests from Twilio correctly.

Great work! You’re now validating the requests to ensure that they’re actually from Twilio and not malicious actors. The only downside now is that we have to add this validation checking code to every Twilio webhook we created. Wouldn’t it be better if we could somehow tag the controller so that it’s done automatically for us? Using Symfony’s Built-in events we can do exactly that!

Listening for Symfony Kernel Events

Symfony dispatches a range of events over the duration of a request’s lifecycle. Most notably in our case is the kernel.controller event. This event is dispatched after the controller to be executed has been resolved but not yet executed. This is the perfect time to validate the Twilio request as we know which controller the request matches, but the action has not yet been performed.

Create a new folder called EventListener in your src directory. Inside the src/EventListener directory, create a file called TwilioRequestListener.php and add the following:

<?php
// src/EventListener/TwilioRequestListener.php

namespace App\EventListener;

use App\Controller\TwilioWebhookController;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Twilio\Security\RequestValidator;

class TwilioRequestListener
{
    private $validator;

    public function __construct(RequestValidator $validator)
    {
        $this->validator = $validator;
    }

    public function onKernelController(ControllerEvent $event)
    {
        $controller = $event->getController();

        if (is_array($controller)) {
            $controller = $controller[0];
        }

        if ($controller instanceof TwilioWebhookController) {
            $request = $event->getRequest();
            $signature = $request->headers->get('X-Twilio-Signature');

            if (is_null($signature)) {
                throw new BadRequestHttpException('X-Twilio-Signature header required');
            }

            $isRequestValid = $this->validator->validate(
                $signature,
                $request->getUri(),
                $request->request->all()
            );

            if (!$isRequestValid) {
                throw new BadRequestHttpException('Request is invalid');
            }
        }
    }
}

You also need to tell Symfony to invoke this event listener when a kernel.controller event is dispatched. Append the following to you config/services.yaml file:

# config/services.yaml

App\EventListener\TwilioRequestListener:
    tags:
        - { name: kernel.event_listener, event: kernel.controller }

When the TwilioRequestListener is invoked, it receives an event with the resolved controller and the current request. You then use the resolved controller to verify it matches the TwilioWebhookController you created earlier. If the controller matches, then you execute the validation logic which is exactly the same as what you did earlier inside the controller action.

Now head back over to the TwilioWebhookController class and replace it with the following:

<?php

// src/Controller/TwilioWebhookController.php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Twilio\Rest\Client;

/**
 * @Route("/webhooks/twilio")
 */
class TwilioWebhookController extends AbstractController
{
    /**
     * @Route("/sms/incoming", name="webhook.twilio.sms_incoming")
     * @param Request $request
     * @param Client $twilioClient
     * @return Response
     * @throws \Twilio\Exceptions\TwilioException
     */
    public function handleIncomingSmsMessage(Request $request, Client $twilioClient)
    {
        $from = $request->request->get('From');

        $now = new \DateTime();

        $twilioClient->messages->create($from, [
            "body" => $now->format('Y-m-d H:i:s'),
            "from" => $this->getParameter('twilio_number')
        ]);

        return new Response();
    }
}

All of the validation has been offloaded to the TwilioRequestListener so you can remove it from the controller. Furthermore, any additional webhooks added to this controller will also be validated automatically.

Ensure the validation still works by sending yourself a request using your API client. You should receive either the missing header or invalid request error responses. Now send the Twilio number an SMS and you should still receive a response.

Conclusions

Congratulations! You have successfully implemented automatic Twilio request validation to ensure API requests to your webhooks are actually from Twilio and not a malicious actor. You should now also understand the importance of validating webhooks from external services and how to utilize a Symfony Event Listener to abstract the logic so that you don’t have to repeat yourself.

Alex Dunne is a Software Engineer based in Birmingham, UK. He is often found experimenting with new technology to embrace into an existing well-rounded skill set.