Secure your video conference with one-time passcodes

May 11, 2020
Written by
Reviewed by

How to protect your video conference with one-time passcodes

As we dutifully practice social distancing, live video conferencing is increasingly popular. From company meetings to yoga classes and magic shows, traditional in person events are going virtual. But while technology connects us, it also comes with privacy and security risks.

This post will show you how to add one-time passcode authentication on top of your Twilio Video application to ensure that only registered users are able to access the conference.

While passwords may help protect against war dialing, they don't guarantee that the people joining the video conference should be allowed to participate. A lot of people are still widely sharing Zoom meeting IDs and passwords.

One-time passcode authentication is useful for gating:

  • Paid content like workout classes, political fundraisers, or live dating shows.
  • Sensitive content with an access control list (ACL)

This tutorial will walk you through adding Twilio Verify SMS verification to a Twilio Video application using Python and Flask. You can find the completed project on my GitHub.

Setting up

To code along with this post you'll need:

This tutorial builds off the great Python, Flask, and Twilio Video tutorial my colleague Miguel wrote last week. You can follow along with that to learn more about the Video API or clone the completed repo:

git clone https://github.com/miguelgrinberg/flask-twilio-video.git && cd flask-twilio-video

Here's the diff between the Verify version and Miguel's original, if you need to reference it at any point. Set up your virtual environment and download the requirements with the following command:

virtualenv venv
source venv/bin/activate

Next create a .env file. Our project needs a few more keys than Miguel's template, so copy the following into your .env file:

# find here: twilio.com/console
TWILIO_ACCOUNT_SID='ACxx…'
TWILIO_AUTH_TOKEN=

# create one here: twilio.com/console/video/project/api-keys
TWILIO_API_KEY_SID=
TWILIO_API_KEY_SECRET=

# create one here: twilio.com/console/verify/services
VERIFY_SERVICE_SID=

# used for session storage
# change this to something difficult to guess!
SECRET_KEY='super-secret'

The Account SID and Auth Token are used to authenticate and instantiate the Twilio Client to send verifications. The API Key is used for the video API. Your Verify service contains a set of common configurations for sending verifications like an application name and token length.

At this point you have everything you need to start up the un-authenticated version of the video app. Make sure everything is working correctly by starting up the application:

export FLASK_ENV=development && flask run

Navigate to localhost:5000 and you should be able to join the video conference.

screenshot of video chat app with 1 participant online

How to add authentication to your video calls

To join the call, you could type in whatever name you want. We want to make sure that we know and trust the people joining the call, so first we'll create an Access Control List (ACL). Open up app.py and add the following function:

def get_participant(identity):
   # Hard coded for demo purposes
   # Use your customer DB in production!
   KNOWN_PARTICIPANTS = {
       'blathers': '+18005559876',
       'mabel': '+18005554321',
       'tommy': '+18005556789'
   }
   return KNOWN_PARTICIPANTS.get(identity)

Add yourself as a known participant; make sure your phone number is in E.164 format. This checks that the identity you entered, in our case a username, and associates it with a known contact channel. You could look up a user with anything you want here: a name, username, email, account number, or anything else unique. This example is going to use SMS verification so we're returning a contact phone number, but you could also use email.

In your login function, add the following check for a known participant:

@app.route('/login', methods=['POST'])
def login():
   username = request.get_json(force=True).get('username')
   if not username:
       abort(401)
  
+   phone_number = get_participant(username)
+   if not phone_number:
+       abort(401)

+   session['username'] = username
  
   token = AccessToken(twilio_account_sid, twilio_api_key_sid,
                       twilio_api_key_secret, identity=username)
   token.add_grant(VideoGrant(room='My Room'))

   return {'token': token.to_jwt().decode()}

We're going to be saving some details in a Flask Session so we can reference them later, so add the following after app = Flask(__name__):

from flask import session
app.secret_key = os.environ.get('SECRET_KEY')

Now your application will only let you join if you're a known participant, try it with a random name and you should get an error message. That's progress, but someone could still guess the identity of an invited guest.

One-time passcode verification with Twilio Verify

Next we're going to add Twilio Verify to our application. Add the following to app.py:

from twilio.rest import Client
twilio_auth_token = os.environ.get('TWILIO_AUTH_TOKEN')
verify_service_sid = os.environ.get('VERIFY_SERVICE_SID')

def _get_verify_service():
   client = Client(twilio_account_sid, twilio_auth_token)
   return client.verify.services(verify_service_sid)


def start_verification(to):
   service = _get_verify_service()
   service.verifications.create(to=to, channel='sms')


def check_verification(to, code):
   service = _get_verify_service()
   check = service.verification_checks.create(to=to, code=code)
   return check.status == 'approved'

We'll use the functions to send a one-time passcode (OTP) to the participant's phone number and check that the code they input is correct. Back in the login function add a call to start_verification:

def login():
   username = request.get_json(force=True).get('username')
   if not username:
       abort(401)

   phone_number = get_participant(username)
   if not phone_number:
       abort(401)
   session['username'] = username

   start_verification(phone_number)

   token = AccessToken(twilio_account_sid, twilio_api_key_sid,
                       twilio_api_key_secret, identity=username)
   token.add_grant(VideoGrant(room='My Room'))

   return {'token': token.to_jwt().decode()}

Now if you rejoin the video conference you should also receive an SMS with your service name and a one-time passcode.

screenshot of otp message

Next we need to reconfigure our Python code so we can both start and check the verification. Add a new route and function called to verify. Here we only generate the AccessToken if the user has successfully verified the OTP.

@app.route('/verify', methods=['POST'])
def verify():
   username = session['username']
   phone_number = get_participant(username)
   code = request.get_json(force=True).get('code')
   if not check_verification(phone_number, code):
       abort(401)

   token = AccessToken(twilio_account_sid, twilio_api_key_sid,
                       twilio_api_key_secret, identity=username)
   token.add_grant(VideoGrant(room='My Room'))

   return {'token': token.to_jwt().decode()}

Replace the token AccessToken lines in your login function with a new return statement. We will use this to tell the user to check their phone for the code.

def login():
   username = request.get_json(force=True).get('username')
   if not username:
       abort(401)

   phone_number = get_participant(username)
   if not phone_number:
       abort(401)
   session['username'] = username
   session['phone'] = phone_number

   start_verification(phone_number)
-
-   token = AccessToken(twilio_account_sid, twilio_api_key_sid,
-                       twilio_api_key_secret, identity=username)
-   token.add_grant(VideoGrant(room='My Room'))
-
-   return {'token': token.to_jwt().decode()}
+   return {'phone': '********{}'.format(phone_number[-2:])}

Let's update the template to add a new form field for the Verify token. Open up templates/index.html and add the following form. You'll notice it's hidden by default.

       <p id="count">Disconnected.</p>
+       <form id="verify" style="display: none;">
+           Code: <input type="text" id="code">
+           <button id="check_code">Verify code</button>
+       </form>
       <div id="container" class="container">

Next we need to update our JavaScript code to handle our new verification workflow. Open up static/app.js and add the following constants to grab the form and the user's code input:

const codeInput = document.getElementById('code');
const verifyForm = document.getElementById('verify');

Then update the code inside connectButtonHandler to show the Verify form when someone submits their name:

       connect(username).then(() => {
-           button.innerHTML = 'Leave call';
-           button.disabled = false;
+           verifyForm.style.display = "";
       }).catch(() => {
-           alert('Connection failed. Is the backend running?');
+           alert("Error - invalid or unknown user");

This also updates the error message in the catch block to indicate that a failure is because of an unknown user. Everything else in that function will stay the same.

screenshot of app with updated otp code form

Below the connectButtonHandler add a new verifyButtonHandler.

function verifyButtonHandler(event) {
   event.preventDefault();
   const code = codeInput.value;
   verify(code).then(() => {
       verifyForm.style.display = "none";
       button.innerHTML = 'Leave call';
       button.disabled = false;
   }).catch(() => {
       alert("Error - invalid code");
       button.innerHTML = 'Join call';
       button.disabled = false;
   });
};

Replace the existing connect function with two functions:

  1. An updated connect function that handles the login endpoint. The login endpoint used to return an AccessToken JWT, now it kicks off the phone verification process. The updated connect function will show our Verify form and tell the user to input the token.
  2. A new verify function that handles the verify endpoint. This will check the verification code and handle the AccessToken JWT.
function connect(username) {
   var promise = new Promise((resolve, reject) => {
       // start the phone verification process
       fetch('/login', {
           method: 'POST',
           body: JSON.stringify({'username': username})
       }).then(res => res.json()).then(data => {
           count.innerHTML = `Sent code to phone number ${data.phone}. Enter the code to verify.`;
           resolve();
       }).catch(() => {
           reject();
       });
   });
   return promise;
};

function verify(code) {
   var promise = new Promise((resolve, reject) => {
       // get a token from the back end
       fetch('/verify', {
           method: 'POST',
           body: JSON.stringify({'code': code})
       }).then(res => res.json()).then(data => {
           // join video call
           return Twilio.Video.connect(data.token);
       }).then(_room => {
           room = _room;
           room.participants.forEach(participantConnected);
           room.on('participantConnected', participantConnected);
           room.on('participantDisconnected', participantDisconnected);
           connected = true;
           updateParticipantCount();
           resolve();
       }).catch(() => {
           reject();
       });
   });
   return promise;
};

Finally, add an event listener at the bottom of app.js to handle the verify form submit:

verifyForm.addEventListener('submit', verifyButtonHandler);

Save your files and refresh or restart the application. You should only be able to join the video conference if you successfully enter the code sent to your device. Pretty neat!

Wrapping Up

Check out the completed version on my GitHub or the diff between my version with Verify and Miguel's original video app.

For more information on the video side of things, I definitely recommend giving Miguel's post a read. If you're interested in more verification and security, here are some more resources to check out:

Find me on Twitter if you have any questions. I can't wait to see what you build!