Trusted Group Authentication with SMS and Express
Published on April 10, 2024

Introduction

Working solo on a project can sometimes be tedious and lonely. That's why collaborating with friends can make the process much more enjoyable! However, arranging meetups and finding convenient times for everyone can be a hassle. Thankfully, there's a solution - building a web application that allows you to work together online seamlessly.

This tutorial will guide you through creating a simple authentication flow using the Vonage Verify API. This will enable your friends to securely access the collaborative app without dealing with complex password policies or encryption. Instead, you'll leverage their phone numbers and Vonage's verification system to authenticate and keep them logged in via session cookies.

tl;drI if you would like to skip ahead and immediately deploy the application, you can find all of the necessary code hosted on GitHub.

Prerequisites

Before we dive in, make sure you have:

  1. Node.js and npm installed

  2. Some basic familiarity with Express.js and SQLite

  3. A Vonage API account

  4. A virtual Vonage number with SMS capabilities

Vonage API Account

To complete this tutorial, you will need a Vonage API account. If you don’t have one already, you can sign up today and start building with free credit. Once you have an account, you can find your API Key and API Secret at the top of the Vonage API Dashboard.

This tutorial also uses a virtual phone number. To purchase one, go to Numbers > Buy Numbers and search for one that meets your needs.

Optionally, you can use a tool like ngrok to expose your local development server during testing publicly.

Setting Up

Start by creating a new Node.js project and installing the required packages:

npm init npm install express sqlite3 connect-sqlite3 cookie-parser express-session @vonage/server-sdk

This will add Express, SQLite3 database support, middleware for sessions/cookies, and the Vonage API client library.

Next, create a .env file and add your Vonage credentials and virtual number:

API_KEY="YOUR_API_KEY" API_SECRET="YOUR_API_SECRET" APP_NUM="YOUR_VIRTUAL_NUMBER"

You'll also want to generate a secret key for your session storage:

SESH_SECRET="a_very_complex_random_string"

Set any other environment variables you may need, such as an invite code, project domain, Vonage app ID, etc.

ADMINS="kelly,michelle,beyonce" INVITE_CODE="code123" PRIVATE_KEY_PATH=private.key APP_ID=49f7d24b-42343ds-vs234-vxcsfds PORT=3003

Configure Express

In your main server file (e.g., app.js), require Express and set up the basic server:

const express = require('express');
const app = express();
const port = 3000;
 
app.listen(port, () => {
  console.log(`Server running on port ${port}`);
});

Next, add middleware to parse JSON requests, manage sessions, and enable SQLite3 session storage:

// parse client requests in JSON
app.use(express.json());
 
// install packages to do session management
const session = require('express-session');
const SQLiteStore = require('connect-sqlite3')(session);
 
// configure automatic session storage in SQLite db
app.use(require('cookie-parser')());
app.use(session({
  store: new SQLiteStore,
  secret: process.env.SESH_SECRET || 'your-secret-key-here', // Provide a secret option
  resave: false,
  saveUninitialized: false,
  cookie: { maxAge: 7 * 24 * 60 * 60 * 1000 } // 1 week
}));

Don't forget to initialize the Vonage Server SDK object with your API credentials:

const Vonage = require('@vonage/server-sdk');
const vonage = new Vonage({
  apiKey: process.env.API_KEY,
  apiSecret: process.env.API_SECRET, 
  applicationId: process.env.APP_ID,
  privateKey: process.env.PRIVATE_KEY_PATH,
});

Setting Up SQLite Database

You'll need a few SQLite tables to store user data and sessions during the authentication flow:

const sqlite3 = require('sqlite3').verbose();
const db = new sqlite3.Database(':memory:');
 
db.serialize(function(){
  if (!exists) {
    db.run('CREATE TABLE Sessions (phone NUMERIC, id TEXT)');
    db.run('CREATE TABLE allowlist (phone NUMERIC, username TEXT)');
    db.run('CREATE TABLE Authors (username TEXT)');
  }
});

The Sessions table temporarily holds user phone numbers and Vonage request IDs during verification. The Allowlist tracks permitted sign-ups, optionally with a pre-approved username. Authors is the persisted list of authenticated usernames.

Handle Routes

Routes for Your Views

Your server already has a route for the main page at /. Below it, you can add two more routes for a signup or login page for your users and an admin page for you:

// View Routes
app.get('/', function(request, response) {
  response.sendFile(__dirname + '/views/index.html');
});
 
app.get('/signup', function(request, response) {
  response.sendFile(__dirname + '/views/signup.html');
});
 
app.get('/admin', function(request, response) {
  if (isAdmin(request.session)) {
    response.sendFile(__dirname + '/views/admin.html');
  } else {
    response.sendFile(__dirname + '/views/index.html'); 
  }
});

To begin managing access to your admin functionality, you must also declare the isAdmin function referenced in your /admin route. It'll split your list of admins from .env out into an array and look for an exact match with the username in the current session:

function isAdmin(sesh) {
  let admins = process.env.ADMINS.split(',');
  return admins.includes(sesh.username);
}

The Admin Endpoint

The first step in the workflow of adding a user is for an admin to add their phone number to an allowlist. Because the same person may want to log in from different devices (thus requiring additional session cookies), or their session may expire, the admin can optionally associate the phone number with an existing username.

To start, declare the endpoint at /invite and add a security check to make sure this person is still an admin:

app.post('/invite', function(request, response) {
  if (!isAdmin(request.session)) {
    response.status(500).send({message: "Sorry, you're not an admin"});
    return;
  }
 
});

If the person attempting to add an invitation isn't an admin, the request should fail.

The next thing the function will do is get the phone number from the request. After a light validity check, it's added to the allowlist. If the admin has specified a username, that will get added too:

app.post('/invite', function(request, response) {
  if (!isAdmin(request.session)) {
    ...
  }
  let phone = request.body.phone;
  if (!isNaN(phone)) {
    if (request.body.username) {
      db.run('INSERT INTO Allowlist (phone, username) VALUES ($phone, $user)', {
        $phone: phone,
        $user: request.body.username
      });
    } else {
      db.run('INSERT INTO Allowlist (phone) VALUES ($phone)', {
        $phone: phone
      });
    }
 
  }
});

Once the new phone number is added to the allowlist, the last thing to do is text an invite to the new user. The text will be sent by the phone number you've saved in .env, and the user will receive the current invite code to text back in reply.

You could skip this step entirely and send the verification PIN. However, this allows you to provide any contextual information the user may benefit from, such as the signup URL. Since Vonage Verify PINs are only good for five minutes, it also helps ensure the recipient's PIN doesn't expire before they see it:

app.post('/invite', function(request, response) {
  if (!isAdmin(request.session)) {
    ...
  }
  let phone = request.body.phone;
  if (!isNaN(phone)) {
    if (request.body.username) {
      ...
    } else {
      ...
    }
    vonage.messages.send(
      new SMS(
        `Please reply to this message with "${process.env.INVITE_CODE}" to get your PIN.`,
        phone,
        process.env.APP_NUM,
      ),
    );
  }
});

The Webhook Endpoint

Before you can receive incoming texts with the Vonage Messages API, we need to configure our application settings in the Vonage dashboard. We will purchase a virtual number and set the Inbound Webhook URL under the number's configuration to our local ngrok URL that exposes our Node.js server.

For example, we can set the Inbound Webhook URL to something like http://1234abc.ngrok.io/answer to send incoming messages to our local /answer endpoint.

With your phone number configured, you can add the logic for the webhook endpoint. You'll check that the text contains the current invite code and that the phone number it came from is in the allowlist. If those conditions are met, you'll send a request for verification and save the phone number and ID you get in response in your Sessions database:

app.post('/answer', function(request, response) {
  let from = request.body.from;
  if (request.body.text === process.env.INVITE_CODE) {
    db.all('SELECT * from Allowlist WHERE phone = $from', 
      {$from: from}, 
      function(err, rows) {
      if (rows.length) {
        vonage.verify.request({
          number: from,
          brand: process.env.PROJECT_DOMAIN
        }, (err, result) => {
          db.run('INSERT INTO Sessions (phone, id) VALUES ($phone, $id)', {
            $phone: from,
            $id: result.request_id
          });
          response.status(204).end();
        });
      }
    });
  }
});

This time, the new user will receive a text generated automatically by Vonage Verify containing their PIN. You've supplied your application's phone number and the domain on ngrok to identify, but other than that, the text is boilerplate. There needs to be something for the user to respond to in this message. With a record of it stored, we'll wait for them to complete the final step through the web app.

The Signup or Login Endpoint

The new user will send their phone number, username, and PIN from the web client. Only the username will be stored. The other values are for the authentication process, and if this login succeeds, we'll remove them from the data store.

Add a new /login endpoint to your server, and as its first step, do some quick validation of the username. The example here only allows basic characters, which might be fine for your purposes, or you might want a more robust set of options. With that validated, you'll find the session for the phone number supplied:

app.post('/login', function(request, response) {
  let allowed = RegExp('[A-Za-z0-9_-]+');
  let username = request.body.username;
  if (!allowed.test(username)) {
    return;
  }
  db.each('SELECT * FROM Sessions WHERE phone = $phone', {
    $phone: request.body.phone
  }, function(error, sesh) {
 
  }); 
});

Within the callback providing the session row, you'll do another check on the username: this time seeing whether it's already in use and, if so, if this phone number is authorized to log in with it:

app.post('/login', function(request, response) {
  ...
  db.each('SELECT * FROM Sessions WHERE phone = $phone',{
    $phone: request.body.phone
  }, function(error, sesh) {
 
    let broken = false;
    db.all('SELECT * FROM Authors WHERE username = $user', {
      $user: username
    }, function(err, rows) {
 
      if (rows.length) {
        db.all('SELECT * FROM Allowlist WHERE username = $user AND phone = $phone', {
          $user: username,
          $phone: sesh.phone
        }, function(e, r) {
          if (e || !r.length) broken = true; 
        });
      }   
    });
 
    if (!broken) {
 
    }
  }); 
});

If the checks on the username all pass, you'll use a flag to confirm it's OK to continue and confirm the PIN received from the client is correct for this verification request. If it is, you'll get a status of 0 in the response and can then safely delete the session and allowlist records you used during this process. The last step is to put the username in the session:

app.post('/login', function (request, response) {
  let allowed = RegExp('[A-Za-z0-9_-]+');
  let username = request.body.username;
  if (!allowed.test(username)) {
    response.status(500).send({ message: 'Please use basic characters for your username' });
    return;
  }
  db.each('SELECT * FROM Sessions WHERE phone = $phone', {
    $phone: request.body.phone
  }, function (error, sesh) {
 
    db.all('SELECT * FROM Authors WHERE username = $user', { $user: username }, function (err, rows) {
      if (rows.length) {
        db.all('SELECT * FROM Allowlist WHERE username = $user AND phone = $phone', {
          $user: username,
          $phone: sesh.phone
        }, function (e, r) {
          if (e || !r.length) {
            response.status(500).send({ message: 'Please choose a different username' });
            return;
          }
        });
      }
 
      vonage.verify.check(sesh.id, request.body.pin)
        .then(result => {
          if (result && result.status === '0') {
            db.serialize(function () {
              db.run('INSERT INTO Authors (username) VALUES ($user)', {
                $user: username
              });
              db.run('DELETE FROM Allowlist WHERE phone = $phone', {
                $phone: sesh.phone
              });
              db.run('DELETE FROM Sessions WHERE phone = $phone', {
                $phone: sesh.phone
              });
            });
            request.session.username = username;
            response.status(200).send({ message: "Success" });
          }
        })
        .catch(err => {
          // handle errors
          console.error(err);
 
          if (err) {
            console.log('Error occurred:', err);
            response.status(500).send({ message: 'Error verifying your info' });
          }
        });
    });
  });
});

Add Some Markup

To collect data on the client side, you'll need two similar forms: an admin form and a signup form. The admin form will trigger invitations to new users, and the signup form will create new sessions. Your index.html page will be a landing page for your project; you can use it to supply whatever information or functionality you want. However, copying its contents to admin.html and signup.html may be useful so you have your scaffolding in place.

Within the <main> tag in admin.html, replace the HTML with a simple form to collect a phone number and username:

<main>
  <h2>
    Invite people to your application  
  </h2>
 
  <form action="/invite" method="post">
    <label>Phone number:
      <input type="phone" id="phone" name="phone">
    </label>
 
    <label>Username (optional):
      <input type="text" id="username" name="username">
    </label>
 
    <input type="submit" value="Invite" id="invite_btn">
    <h3 id="feedback"></h3>
  </form>
</main>

The contents of <main> in signup.html should be very similar, except that there you'll also collect a PIN:

<main>
  <h2>
    Sign up or log in
  </h2>
 
  <form action="/login" method="post">
    <label>Phone number:
      <input type="phone" id="phone" name="phone">
    </label>
 
    <label>Username:
      <input type="text" id="username" name="username">
    </label>
 
    <label>PIN:
      <input type="text" id="pin" name="pin">
    </label>
        <input type="submit" value="Sign up" id="signup_btn">
        <h3 id="feedback"></h3>
      </form>
</main>

By keeping the default HTML structure, you'll continue to link client.js across both pages. Since the forms on these pages share a similar design, a single script file, client.js, can efficiently manage their functionality. After resetting client.js to a blank state, it's the perfect place to implement your customized script.

Within this script, start by identifying and collecting the form elements crucial for interaction. Next, detect the submit buttons on both forms and establish click event listeners. These listeners will invoke a common function that handles form data submission to the server while also intercepting and preventing the standard form submission behavior. Although it's possible to manage form submissions purely with HTML, employing JavaScript for this task offers enhanced flexibility, especially as your application evolves and demands more sophisticated handling.

let phone = document.querySelector('#phone');
let username = document.querySelector('#username');
let feedback = document.querySelector('#feedback');

// Invite Form
let invite_btn = document.querySelector('#invite_btn');
if (invite_btn) {
  invite_btn.onclick = function(e) {
    e.preventDefault();
    let body = JSON.stringify({
      phone: phone.value,
      username: username.value
    });
    goFetch('/invite', body, e.target);
    return false;
  };
}

// Sign Up From
let signup_btn = document.querySelector('#signup_btn');
if (signup_btn) {
  signup_btn.onclick = function(e) {
    e.preventDefault();
    let body = JSON.stringify({
      phone: phone.value,
      username: username.value,
      pin: document.querySelector('#pin').value
    });
    goFetch('/login', body, e.target);
    return false;
  };
}

function goFetch(url, body, btn) {
  fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: body
  })
  .then(response =&gt; response.json())
  .then(data =&gt; {
    feedback.innerText = data.message || 'Thank you!';
    btn.style.display = 'none';
  });
}

Run the Project

To start the project, run ‘npm run start’ from your terminal on the same port as where you have ngrok running.

Note: Your application is ready, but there's a catch: you need admin rights to invite others, yet obtaining these rights requires an invite. Addressing this issue could involve creating a workaround for developers (like you) with complete access to the code. A quick fix is to modify your code's isAdmin function to return true, temporarily allowing unrestricted access.

Conclusion and Next Steps

You have reached the end of this tutorial! Adding error handling will refine user interactions, such as selecting an already-used username or inputting invalid characters. Furthermore, establishing user management endpoints, along with strategies for managing allowlists and extending session lifetimes based on user activity or through a renewal feature, will facilitate continuous access while minimizing the reliance on regular SMS verifications.

The code for this tutorial is available on GitHub.

To get the latest news, connect with us on our Developer Community Slack, on X, previously known as Twitter, and at events.

Amanda CavallaroDeveloper Advocate

Ready to start building?

Experience seamless connectivity, real-time messaging, and crystal-clear voice and video calls-all at your fingertips.

Subscribe to Our Developer Newsletter

Subscribe to our monthly newsletter to receive our latest updates on tutorials, releases, and events. No spam.