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:
- Python 3.6 or newer.
- A free or paid Twilio account. If you are new to Twilio get your free account now! This link will give you $10 when you upgrade.
- A web browser that is compatible with the Twilio Programmable Video JavaScript SDK.
- A Verify service. This contains a set of common configurations for sending verifications like an application name and token length. Create one in the console.
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.
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.
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.
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:
- An updated
connect
function that handles thelogin
endpoint. Thelogin
endpoint used to return an AccessToken JWT, now it kicks off the phone verification process. The updatedconnect
function will show our Verify form and tell the user to input the token. - A new
verify
function that handles theverify
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:
- How to build a one-time passcode protected conference line with Twilio Verify and Python
- Serverless Phone Verification with Twilio Verify and Twilio Functions
- Verify email channel
- Is email based 2FA a good idea?
Find me on Twitter if you have any questions. I can't wait to see what you build!