Multi-factor authentication in Go using Twilio Verify API

November 26, 2024
Written by
Desmond Obisi
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Multi-factor Authentication in Go using Twilio Verify API

As web applications become increasingly complex and security-critical, implementing robust authentication mechanisms is paramount. An excellent mechanism is Multi-factor authentication (MFA) adds an extra layer of security beyond traditional password-based systems.

Multi-factor authentication in Go applications enhances their security by requiring users to provide multiple forms of identification before gaining access. This approach significantly reduces the risk of unauthorized access, even if a user's password is compromised.

In this article, we'll explore how to implement MFA in a Go fullstack application using the Twilio Verify API and integrate it with a Vue.js frontend.

Prerequisites

Here are some requirements you need to follow in this tutorial:

  • Go 1.22 or above installed on your system
  • The Twilio Authy app installed on your phone
  • A free Twilio account (free or paid). Click here to sign up if you don't already have an account.
  • Ngrok, and make sure it’s authenticated
  • Basic knowledge of Vue.js
  • Familiarity with RESTful API concepts

Understanding multi-factor authentication with Twilio Verify

Twilio Verify is a powerful API that enables developers to add various forms of authentication to their applications. It supports SMS, voice, email, and push notifications for verification purposes. In our Go application, we'll focus on SMS verification and Time-based One-Time Passwords (TOTP) using Authy.

The architecture

Our MFA system consists of several key components:

  • User Registration: Collects user information and initiates the MFA setup process
  • Login: Handles initial authentication with username and password
  • SMS Verification: Sends and verifies one-time codes via SMS
  • TOTP Setup: Configures Time-based One-Time Password using Authy

Key components

  • Go Backend: Handles API requests, interacts with Twilio Verify, and manages user authentication
  • Vue.js Frontend: Provides the user interface for registration, login, and MFA processes
  • Twilio Verify API: Manages SMS sending and verification
  • Authy: Handles TOTP generation and verification

Benefits of MFA in Go applications

Multi-factor authentication (MFA) in Go applications offers several key advantages. These benefits enhance both the security posture of the application and the overall user experience. Let's explore the main advantages of this approach:

  • Enhanced security through multiple authentication factors: By combining password-based authentication with SMS verification and TOTP, the application creates multiple layers of security, significantly reducing the risk of unauthorized access.
  • Improved user trust and confidence: Implementing MFA demonstrates a commitment to security, enhancing user trust in the application and potentially increasing user adoption and retention rates.
  • Compliance with industry standards: Many regulatory frameworks and industry standards require or strongly recommend MFA, making its implementation crucial for applications handling sensitive data or operating in regulated industries.
  • Mitigation of common attack vectors: MFA effectively counters various attack methods such as phishing, credential stuffing, and brute force attacks, providing a robust defense against common security threats.
  • Scalability and performance: Go's concurrent processing capabilities allow for efficient handling of MFA requests, ensuring the authentication process remains fast and responsive even as user numbers grow.

Use case with Twilio Verify API

In our demo, we'll build a fullstack application that implements a secure login process with the following steps:

  • User registers an account
  • User logs in with an email and password
  • Server sends SMS OTP via Twilio Verify
  • User verifies SMS OTP
  • User sets up TOTP with Authy
  • For subsequent logins, the user provides SMS and TOTP code after password

Retrieve your Twilio credentials

Login into your Twilio Console using your account details. From the toolbar's click Account > Account Management. Then, in the left-hand side navigation menu, under Keys & Credentials select API keys & tokens.

Take note of the test credentials as shown in the photo below, which include the Account SID and Auth Token, we will use them later in your application.

Remember not to share these keys as they will give anyone with them access to your Twilio account and they can perform unauthorized activities

After getting your authentication tokens, you will create a Verify service from the dashboard. Click on Explore Products from the toolbar’s menu, click Verify, select Services, and click on the Create button to create one.

You will see a screen like the one below. Fill in your service name, select the channels, and add some descriptive notes for the service. For this tutorial, select all the verification channels.

Creating a Verify sercive from Twilio console

After that, click on Continue and select Yes for the Enable Free Guard section.

At this point, your service is created and you will be redirected to a screen like the one below to update any settings and save them. For this tutorial, you do not have to update any of the settings. Just save the settings and your setup is ready.

Setting the service configurations from the console

Finally, take note of the Service SID as you will need it in your project to use this Verify service. You will find it in the next interface after saving the settings .

Create the Go API

In this section we will be creating the backend APIs that are needed for the end to end Multi-factor authentication. You will clone this starter repository which will help us add structure to our application, then install the necessary packages using the commands below:

First, clone the project and change into the new project directory with the following commands.

git clone https://github.com/desmondsanctity/twilio-go-verify.git
cd twilio-go-verify

Then, install the required packages with the following command.

go mod tidy

After successful installation of the packages, create a .env file and copy the key values from the .env.example file in the starter repository you cloned. Add the values for these keys and secrets as gotten from your Twilio console following the setup done initially.

In our project structure, we have the cmd, internals, and static directory. The cmd directory holds the entry file main.go, the internals have subdirectories including handlers, models, store and twilio respectively where our logic lives. The static directory has the static files for the Vue.js frontend.

We will start creating the logic for our APIs by updating the files in our model, store, twilio and handlers directory. In the verify.go file inside the internal/ twilio directory, update the code with the version below:

package twilio
import (
	"fmt"
	"github.com/twilio/twilio-go"
	verify "github.com/twilio/twilio-go/rest/verify/v2"
)
type TwilioVerify struct {
	client *twilio.RestClient
	sid    string
}
func NewTwilioVerify(accountSid, authToken, verifySid string) *TwilioVerify {
	client := twilio.NewRestClientWithParams(twilio.ClientParams{
		Username: accountSid,
		Password: authToken,
	})
	return &TwilioVerify{
		client: client,
		sid:    verifySid,
	}
}
func (tv *TwilioVerify) SendSMSOTP(to string) error {
	params := &verify.CreateVerificationParams{}
	params.SetTo(to)
	params.SetChannel("sms")
	_, err := tv.client.VerifyV2.CreateVerification(tv.sid, params)
	return err
}
func (tv *TwilioVerify) VerifySMSOTP(to, code string) (bool, error) {
	params := &verify.CreateVerificationCheckParams{}
	params.SetTo(to)
	params.SetCode(code)
	resp, err := tv.client.VerifyV2.CreateVerificationCheck(tv.sid, params)
	if err != nil {
		return false, err
	}
	return *resp.Status == "approved", nil
}
func (tv *TwilioVerify) CreateTOTPFactor(identity, name string) (string, string, error) {
	params := &verify.CreateNewFactorParams{}
	params.SetFriendlyName(name + "'s totp")
	params.SetFactorType("totp")
	resp, err := tv.client.VerifyV2.CreateNewFactor(tv.sid, identity, params)
	if err != nil {
		return "", "", err
	}
	binding, ok := (*resp.Binding).(map[string]interface{})
	if !ok {
		return "", "", fmt.Errorf("unexpected binding type")
	}
	uri, ok := binding["uri"].(string)
	if !ok {
		return "", "", fmt.Errorf("uri not found in binding or not a string")
	}
	return *resp.Sid, uri, nil
}
func (tv *TwilioVerify) VerifyFactor(factorSid, code string, identity string) (bool, error) {
	params := &verify.UpdateFactorParams{}
	params.SetAuthPayload(code)
	resp, err := tv.client.VerifyV2.UpdateFactor(tv.sid, identity, factorSid, params)
	if err != nil {
		return false, err
	}
	return *resp.Status == "verified", nil
}
func (tv *TwilioVerify) CreateTOTPChallenge(factorSid string, code string, identity string) (string, error) {
	params := &verify.CreateChallengeParams{}
	params.SetAuthPayload(code)
	params.SetFactorSid(factorSid)
	resp, err := tv.client.VerifyV2.CreateChallenge(tv.sid, identity, params)
	if err != nil {
		return "", err
	}
	return *resp.Sid, nil
}

This file holds the functions for sending SMS OTP, verifying the SMS OTP, creating a TOTP URL or code, verifying the TOTP and subsequently creating a TOTP challenge for use after first verification. It uses Twilio's Go Helper Library to simplify calling the respective API endpoints. As seen, they accept parameters from the handlers and return the respective results back to the handlers.

Next, we will create the handlers logic. In the internal/handlers directory, first, update auth.go with the code below.

package handlers
import (
	"encoding/json"
	"net/http"
	"github.com/desmomndsanctity/twilio-go-verify/internal/models"
	"github.com/desmomndsanctity/twilio-go-verify/internal/store"
	"github.com/desmomndsanctity/twilio-go-verify/internal/twilio"
	"github.com/google/uuid"
	"golang.org/x/crypto/bcrypt"
)
type AuthHandler struct {
	store  *store.InMemoryStore
	twilio *twilio.TwilioVerify
}
func NewAuthHandler(store *store.InMemoryStore, twilio *twilio.TwilioVerify) *AuthHandler {
	return &AuthHandler{
		store:  store,
		twilio: twilio,
	}
}
func (h *AuthHandler) SignUp(w http.ResponseWriter, r *http.Request) {
	var user models.User
	if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
		http.Error(w, "Invalid request body", http.StatusBadRequest)
		return
	}
	hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
	if err != nil {
		http.Error(w, "Error hashing password", http.StatusInternalServerError)
		return
	}
	user.ID = uuid.New().String()
	user.Password = string(hashedPassword)
	user.IsAuthenticated = false
	if err := h.store.CreateUser(&user); err != nil {
		http.Error(w, "Error creating user", http.StatusInternalServerError)
		return
	}
	w.WriteHeader(http.StatusCreated)
}
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
	var credentials struct {
		Email    string `json:"email"`
		Password string `json:"password"`
	}
	if err := json.NewDecoder(r.Body).Decode(&credentials); err != nil {
		http.Error(w, "Invalid request body", http.StatusBadRequest)
		return
	}
	user, err := h.store.GetUserByEmail(credentials.Email)
	if err != nil {
		http.Error(w, "Invalid credentials", http.StatusUnauthorized)
		return
	}
	if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(credentials.Password)); err != nil {
		http.Error(w, "Invalid credentials", http.StatusUnauthorized)
		return
	}
	// Create a response struct with the user information you want to send
	response := struct {
		ID              string `json:"id"`
		Name            string `json:"name"`
		Email           string `json:"email"`
		PhoneNumber     string `json:"phoneNumber"`
		SMSEnabled      bool   `json:"smsEnabled"`
		TOTPEnabled     bool   `json:"totpEnabled"`
		IsAuthenticated bool   `json:"isAuthenticated"`
	}{
		ID:              user.ID,
		Name:            user.Name,
		Email:           user.Email,
		PhoneNumber:     user.PhoneNumber,
		SMSEnabled:      user.SMSEnabled,
		TOTPEnabled:     user.TOTPEnabled,
		IsAuthenticated: user.IsAuthenticated,
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(response)
}
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Email string `json:"email"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Invalid request body", http.StatusBadRequest)
		return
	}
	user, err := h.store.GetUserByEmail(req.Email)
	if err != nil {
		http.Error(w, "User not found", http.StatusNotFound)
		return
	}
	user.IsAuthenticated = false
	if err := h.store.UpdateUser(user); err != nil {
		http.Error(w, "Error updating user", http.StatusInternalServerError)
		return
	}
	w.WriteHeader(http.StatusOK)
}

The logic here uses the in-memory module form internal/store/inmemory.go to interact with the app memory to create users and store them in the app memory. This module uses sync, a Go standard library package for this operation.

The logic also handles login by using the bcrypt library to check the hashed password and original password to match. It also implements the logout function which removes the authentication status for the user so that they will have to authenticate upon logging in again.

Next, update internal/handlers/user.go to match the code below.

package handlers
import (
	"encoding/json"
	"net/http"
	"github.com/desmomndsanctity/twilio-go-verify/internal/store"
)
type UserHandler struct {
	store *store.InMemoryStore
}
func NewUserHandler(store *store.InMemoryStore) *UserHandler {
	return &UserHandler{
		store: store,
	}
}
func (h *UserHandler) GetUserInfo(w http.ResponseWriter, r *http.Request) {
	email := r.URL.Query().Get("email")
	if email == "" {
		http.Error(w, "Email is required", http.StatusBadRequest)
		return
	}
	user, err := h.store.GetUserByEmail(email)
	if err != nil {
		http.Error(w, "User not found", http.StatusNotFound)
		return
	}
	response := struct {
		Name            string `json:"name"`
		Email           string `json:"email"`
		SMSEnabled      bool   `json:"smsEnabled"`
		TOTPEnabled     bool   `json:"totpEnabled"`
		TOTPFactorSid   string `json:"totpFactorSid"`
		IsAuthenticated bool   `json:"isAuthenticated"`
	}{
		Name:            user.Name,
		Email:           user.Email,
		SMSEnabled:      user.SMSEnabled,
		TOTPEnabled:     user.TOTPEnabled,
		TOTPFactorSid:   user.TOTPFactorSid,
		IsAuthenticated: user.IsAuthenticated,
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(response)
}

Here, the user handler logic implements a GetUserInfo() function for getting the user’s information from the in-memory store and returning the necessary details. It accepts an email address from the request’s query parameters and checks it against the memory store to get the matched user details.

Thirdly, update internal/handlers/verify.go to match the code below.

package handlers
import (
	"encoding/json"
	"log"
	"net/http"
	"github.com/desmomndsanctity/twilio-go-verify/internal/store"
	"github.com/desmomndsanctity/twilio-go-verify/internal/twilio"
)
type VerifyHandler struct {
	store  *store.InMemoryStore
	twilio *twilio.TwilioVerify
}
type QRResponse struct {
	QRCode    string `json:"qrCode"`
	FactorSid string `json:"factorSid"`
}
func NewVerifyHandler(store *store.InMemoryStore, twilio *twilio.TwilioVerify) *VerifyHandler {
	return &VerifyHandler{
		store:  store,
		twilio: twilio,
	}
}
func (h *VerifyHandler) SendSMSOTP(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Email string `json:"email"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Invalid request body", http.StatusBadRequest)
		return
	}
	user, err := h.store.GetUserByEmail(req.Email)
	if err != nil {
		http.Error(w, "User not found", http.StatusNotFound)
		return
	}
	if err := h.twilio.SendSMSOTP(user.PhoneNumber); err != nil {
		log.Printf("Failed to send SMS OTP: %v", err)
		http.Error(w, "Failed to send SMS OTP", http.StatusInternalServerError)
		return
	}
	w.WriteHeader(http.StatusOK)
}
func (h *VerifyHandler) VerifySMSOTP(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Email string `json:"email"`
		Code  string `json:"code"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Invalid request body", http.StatusBadRequest)
		return
	}
	user, err := h.store.GetUserByEmail(req.Email)
	if err != nil {
		http.Error(w, "User not found", http.StatusNotFound)
		return
	}
	verified, err := h.twilio.VerifySMSOTP(user.PhoneNumber, req.Code)
	if err != nil {
		log.Printf("Failed to verify SMS OTP: %v", err)
		http.Error(w, "Failed to verify SMS OTP", http.StatusInternalServerError)
		return
	}
	if !verified {
		http.Error(w, "Invalid OTP", http.StatusUnauthorized)
		return
	}
	user.SMSEnabled = true
	if err := h.store.UpdateUser(user); err != nil {
		http.Error(w, "Failed to update user", http.StatusInternalServerError)
		return
	}
	w.WriteHeader(http.StatusOK)
}
func (h *VerifyHandler) CreateTOTPFactor(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Email string `json:"email"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Invalid request body", http.StatusBadRequest)
		return
	}
	user, err := h.store.GetUserByEmail(req.Email)
	if err != nil {
		http.Error(w, "User not found", http.StatusNotFound)
		return
	}
	sid, uri, err := h.twilio.CreateTOTPFactor(user.ID, user.Name)
	if err != nil {
		log.Printf("Failed to create TOTP factor: %v", err)
		http.Error(w, "Failed to create TOTP factor", http.StatusInternalServerError)
		return
	}
	user.TOTPFactorSid = sid
	if err := h.store.UpdateUser(user); err != nil {
		http.Error(w, "Failed to update user", http.StatusInternalServerError)
		return
	}
	response := QRResponse{
		QRCode:    uri,
		FactorSid: sid,
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(response)
}
func (h *VerifyHandler) VerifyFactor(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Email string `json:"email"`
		Code  string `json:"code"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Invalid request body", http.StatusBadRequest)
		return
	}
	user, err := h.store.GetUserByEmail(req.Email)
	if err != nil {
		http.Error(w, "User not found", http.StatusNotFound)
		return
	}
	verified, err := h.twilio.VerifyFactor(user.TOTPFactorSid, req.Code, user.ID)
	if err != nil {
		log.Printf("Failed to verify factor: %v", err)
		http.Error(w, "Failed to verify factor", http.StatusInternalServerError)
		return
	}
	if !verified {
		http.Error(w, "Invalid code", http.StatusUnauthorized)
		return
	}
	user.TOTPEnabled = true
	user.IsAuthenticated = true
	if err := h.store.UpdateUser(user); err != nil {
		http.Error(w, "Failed to update user", http.StatusInternalServerError)
		return
	}
	w.WriteHeader(http.StatusOK)
}
func (h *VerifyHandler) CreateTOTPChallenge(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Email string `json:"email"`
		Code  string `json:"code"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Invalid request body", http.StatusBadRequest)
		return
	}
	user, err := h.store.GetUserByEmail(req.Email)
	if err != nil {
		http.Error(w, "User not found", http.StatusNotFound)
		return
	}
	challengeSid, err := h.twilio.CreateTOTPChallenge(user.TOTPFactorSid, req.Code, user.ID)
	if err != nil {
		log.Printf("Failed to create TOTP challenge: %v", err)
		http.Error(w, "Failed to create TOTP challenge", http.StatusInternalServerError)
		return
	}
	user.IsAuthenticated = true
	if err := h.store.UpdateUser(user); err != nil {
		http.Error(w, "Error updating user", http.StatusInternalServerError)
		return
	}
	json.NewEncoder(w).Encode(map[string]string{"challengeSid": challengeSid})
}

VerifyHandler implements the five key logic functions for our Multi-factor authentication code. It uses the store and twilio variables to make calls to the Twilio API and the in-memory store respectively to create verifications and store the corresponding results.

  • The SendSMSOTP() function uses the email from the request body to find the associated user’s phone number and sends an SMS OTP to the user.
  • The VerifySMSOTP() function also uses the email and code from the request body to confirm and update users, and verify the code using Twilio respectively
  • The CreateTOTPFactor() function creates the TOTP factor using the user ID as identity and user name as the friendly name of the TOTP in the Authy mobile app
  • The VerifyFactor() function uses the code and email from the request body to verify the TOTP factor created and also to update the user's details
  • The CreateTOTPChallenge() function uses the code from the request body to always verify the user subsequently. The codes are generated every 30 seconds in the Authy mobile app.

Finally for the APIs, update our entry file, main.go, in the cmd directory to reflect the logic in our handlers with the code below:

package main
import (
	"log"
	"net/http"
	"os"
	"github.com/desmomndsanctity/twilio-go-verify/internal/handlers"
	"github.com/desmomndsanctity/twilio-go-verify/internal/store"
	"github.com/desmomndsanctity/twilio-go-verify/internal/twilio"
	"github.com/gorilla/mux"
	"github.com/joho/godotenv"
)
func main() {
	err := godotenv.Load()
	if err != nil {
		log.Fatal("Error loading .env file")
	}
	inMemoryStore := store.NewInMemoryStore()
	twilioVerify := twilio.NewTwilioVerify(
		os.Getenv("TWILIO_ACCOUNT_SID"),
		os.Getenv("TWILIO_AUTH_TOKEN"),
		os.Getenv("TWILIO_VERIFY_SID"),
	)
	authHandler := handlers.NewAuthHandler(inMemoryStore, twilioVerify)
	verifyHandler := handlers.NewVerifyHandler(inMemoryStore, twilioVerify)
	userHandler := handlers.NewUserHandler(inMemoryStore)
	r := mux.NewRouter()
	r.HandleFunc("/api/signup", authHandler.SignUp).Methods("POST")
	r.HandleFunc("/api/login", authHandler.Login).Methods("POST")
	r.HandleFunc("/api/logout", authHandler.Logout).Methods("POST")
	r.HandleFunc("/api/verify/send-sms", verifyHandler.SendSMSOTP).Methods("POST")
	r.HandleFunc("/api/verify/verify-sms", verifyHandler.VerifySMSOTP).Methods("POST")
	r.HandleFunc("/api/verify/create-totp", verifyHandler.CreateTOTPFactor).Methods("POST")
	r.HandleFunc("/api/verify/verify-factor", verifyHandler.VerifyFactor).Methods("POST")
	r.HandleFunc("/api/verify/create-totp-challenge", verifyHandler.CreateTOTPChallenge).Methods("POST")
	r.HandleFunc("/api/user", userHandler.GetUserInfo).Methods("GET")
	r.PathPrefix("/").Handler(http.FileServer(http.Dir("./static")))
	log.Println("Server starting on :8080")
	log.Fatal(http.ListenAndServe(":8080", r))
}

The main.go file uses the GoDotEnv package to load the environment variables from .env into the app, initializes the store and twilio instances and uses the gorilla/mux package to define routes in our application. The routes package is also used to serve static files in our app so we can access our user interface in the same server as well as our APIs.

Add the user interface for the multi factor authentication with Vue.js

Now that we are done with the backend service, we will be adding the user interface that our application will be using. If you are using the starter repository, the basic Vue components our application needs are already in place. But, we still need to create an entry file and the router file that Vue will use to render the components.

So, in the static directory, add this code to the index.html file:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Twilio Verify Demo</title>
        <link
            href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
            rel="stylesheet"
        />
        <link href="/css/styles.css" rel="stylesheet" />
        <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
        <script src="https://cdn.jsdelivr.net/npm/vue-router@3.5.3/dist/vue-router.js"></script>
        <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
        <script src="https://cdn.jsdelivr.net/npm/qrcode-generator@1.4.4/qrcode.min.js"></script>
    </head>
    <body class="bg-gray-100">
        <div id="app" class="container mx-auto p-4">
            <router-view></router-view>
        </div>
        <script src="/js/components/Signup.js"></script>
        <script src="/js/components/Login.js"></script>
        <script src="/js/components/SMSVerification.js"></script>
        <script src="/js/components/AuthySetup.js"></script>
        <script src="/js/components/Dashboard.js"></script>
        <script src="/js/router.js"></script>
        <script src="/js/app.js"></script>
    </body>
</html>

The HTML file linked with all the packages we will need in the head tag namely: Tailwind CSS, Vue.js, Vue Router, A xios and qrcode-generator. The body is a div calling the Vue app with the id as “app” and then the router view for navigation between the components. The script section includes all the JavaScript components we have from the starter project.

After this, we will add the router and initialize the Vue app. We will update two files in the static folder here. The first is the router.js and then the app.js all in the js subdirectory.

First, update static/js/router.js to match the code below.

const router = new VueRouter({
    routes: [
        { path: '/', component: Signup },
        { path: '/login', component: Login },
        { path: '/sms-verification', component: SMSVerification },
        { path: '/authy-setup', component: AuthySetup },
        { path: '/dashboard', component: Dashboard },
    ],
});

Then, update static/js/app.js to match the code below.

new Vue({
    el: '#app',
    router: router,
    components: {
        Login,
        Dashboard,
        AuthySetup,
        SMSVerification,
    }
});

The router.js and the app.js file defines the routes for our application using Vue Router, and initializes the app instance by defining the routes and components it will use.

Test that the application works

Our application is now ready for testing. We want to mock a production environment, so we will use ngrok to proxy our local development environment to a live URL. First, start the application with the following command:

go run cmd/main.go

When the app has started successfully, in a new terminal tab or session, start ngrok to create a secure tunnel between the application and the public internet with the following command.

ngrok http 8080
Terminal displaying ngrok status, session details, connection URLs, and update availability.

Now, open the ngrok generated Forwarding URL to see the application interface.

The app starts with the signup page. Add a name, email address, password, and an active phone number for the SMS verification.

Sign up form with fields for name, email, password, and phone number on a Twilio Verify demo page.

When the signup is completed, you will be redirected to the login page as shown below to sign into the app. Log in to the application, which will trigger the Multi-factor authentication request.

Screenshot of a login page with fields for email and password and a blue login button.

After logging in, complete the first verification step, SMS verification. Successfully doing so will trigger the OTP service to send you the OTP via SMS. Input the OTP and proceed as shown below:

SMS verification page of Twilio Verify Demo with input field for SMS code and verify button

The next screen after successful OTP verification is the Twilio Authy app setup, one where you will scan the QR code with the app and the time-based one time passwords will start generating. Input any of the generated passwords to verify.

Authy setup page with QR code and field to enter Authy code, plus a button to enable TOTP.

After a successful verification, you will now be able to access the app’s dashboard, which ends the flow for our Multi-factor authentication demo.

Webpage showing a welcome message and logout button for successful 2FA setup.

Below is also a video demonstration of how the Multi-factor authentication works end-to-end using Go, Vue.js and Twilio Verify.

That's how to implement Multi-factor authentication in Go using Twilio Verify

In this tutorial, we've explored the implementation of Multi-factor authentication in a Go application using the Twilio Verify API. We've walked through the process of building a robust authentication system, from user registration to TOTP setup, integrating both backend and frontend components seamlessly.

Our implementation demonstrates the power of combining Go's efficiency with Vue.js's reactive frontend, creating a secure and responsive user experience. The use of Twilio's Verify API showcases how modern applications can leverage third-party services to implement complex security features without reinventing the wheel.

As you apply these concepts to your own applications, remember that security is an ongoing process. Stay informed about the latest security best practices, regularly update your dependencies, and consider implementing additional security measures such as rate limiting and secure session management.

To deepen your understanding and stay up-to-date with the latest developments, refer to these resources:

Desmond Obisi is a software engineer and a technical writer who loves developer experience engineering. He’s very invested in building products and providing the best experience to users through documentation, guides, building relations, and strategies around products. He can be reached on Twitter, LinkedIn, or at desmond.obisi.g20@gmail.com.