Build a One-Time Password-Based Login System using Go and Twilio

June 03, 2025
Written by
David Fagbuyiro
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Building a One-Time Password (OTP) Login System via Twilio and Go

In a world where data breaches are a constant threat, passwordless authentication methods like One-Time Passwords (OTPs) have become the go-to solution for secure logins. Pairing the simplicity of Go with the robust messaging power of Twilio, you can build a seamless OTP login system in no time.

If you're ready to boost your app's security while keeping things user-friendly? Let’s dive in.

Prerequisites

Before you begin, ensure you have the following:

  • Go version 1.22 or above
  • A Twilio account (either free or paid). If you are new to Twilio, click here to create a free account
  • Access to a MySQL database
  • Basic knowledge of Go
  • A mobile phone that can send SMS

Create the Go application

Initialize a new Go module

To create the Go application, start by creating the Go project and initialising a go module, by running the following commands in your terminal:

mkdir otp-login
cd otp-login
go mod init otp-login

Create the environment variables

To keep your API keys and other sensitive information out of code, you should use environment variables stored in a .env file. Now, create a .env file in the root of your project and add the following to it:

TWILIO_ACCOUNT_SID=<your_twilio_account_sid>
TWILIO_AUTH_TOKEN=<your_twilio_auth_token>
TWILIO_PHONE_NUMBER=<your_twilio_phone_number>
DB_SOURCE=root:@tcp(localhost:3306)/userinfo

Then, update the DB_SOURCE key to match your MySQL credentials. Check out the DSN format documentation, if you're unfamiliar with how the value is formatted.

Environment variables stored in the .env file contain sensitive information, such as Twilio credentials for authentication and messaging (TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_PHONE_NUMBER). The file also holds the database connection string (DB_SOURCE) used to access the user information database.

Keeping this sensitive information separate from the source code enhances security and reduces the risk of accidental exposure.

Retrieve your Twilio access credentials

Next, log in to your Twilio Console to retrieve your Account SID, Auth Token, and Twilio phone number, as you can see in the screenshot below.

Then, use them to replace <your_twilio_account_sid>, <your_twilio_auth_token>, and <your_twilio_phone_number>, respectively, in .env.

Create the database schema

Now, you will create the application database to store the user details. To do that, login to your MySQL server, create a new database named "userinfo" and then run the query below to create a table named "reg_users".

CREATE TABLE `reg_users` (
    `ID` int(11) NOT NULL PRIMARY KEY AUTO_INCREMENT,
    `Fullname` varchar(99) NOT NULL DEFAULT '',
    `Phone` varchar(15) NOT NULL DEFAULT '',
    `Password` varchar(99) NOT NULL DEFAULT ''
);
database to store the user details

The image above shows the structure of the reg_users table in the "userinfo" database. It lists the columns of the table, such as "ID", "Fullname", "Phone", and "Password", along with their data types. Essentially, it's a visual of how the "reg_users" table is set up.

Install the required dependencies

The application will use Twilio's official Helper Library for Go to send SMS, GoDotEnv to create environment variables from the variables defined in .env, and the Go MySQL Driver to interact with MySQL. To install them, run the command below:

go get github.com/twilio/twilio-go github.com/joho/godotenv github.com/go-sql-driver/mysql  github.com/gorilla/mux github.com/gorilla/sessions golang.org/x/crypto/bcrypt

Create the application's logic

The code below implements a user registration and login system with OTP verification by using a MySQL database to store user information, bcrypt for password hashing, and Twilio for sending OTPs via SMS. The application manages user sessions and provides pages for registration, login, OTP verification, and a success dashboard.

The Go code below should be placed in the main.go at the root of your project directory, and ensure that your MySQL database is running and has the required "reg_users" table before executing the program.

package main

import (
	"crypto/rand"
	"database/sql"
	"errors"
	"fmt"
	"html/template"
	"log"
	"math/big"
	"net/http"
	"os"
	"strings"
	"sync"
	"time"

	_ "github.com/go-sql-driver/mysql"
	"github.com/gorilla/mux"
	"github.com/gorilla/sessions"
	"github.com/joho/godotenv"
	"github.com/twilio/twilio-go"
	v2010 "github.com/twilio/twilio-go/rest/api/v2010"
	"golang.org/x/crypto/bcrypt"
)

var (
	db        *sql.DB
	store     = sessions.NewCookieStore([]byte("super-secret-key"))
	otpStore  = make(map[string]string)
	otpExpiry = make(map[string]time.Time)
	mu        sync.Mutex
)

func init() {
	loadEnv()
	initDB()

	store.Options = &sessions.Options{
		//Domain:   config.Host,
		Path:     "/",
		MaxAge:   259200,
		Secure:   false,
		HttpOnly: true,
	}
}

func loadEnv() {
	err := godotenv.Load()
	if err != nil {
		log.Println("Warning: .env file not found, relying on environment variables")
	}
}

func initDB() {
	var err error
	db, err = sql.Open("mysql", os.Getenv("DB_SOURCE"))
	if err != nil {
		log.Fatalf("Database connection failed: %v", err)
	}

	if err = db.Ping(); err != nil {
		log.Fatalf("Database ping failed: %v", err)
	}

	fmt.Println("Database connected successfully!")
}

func renderTemplate(w http.ResponseWriter, tmpl string, data map[string]interface{}) {
	t, err := template.ParseFiles("templates/" + tmpl + ".html")
	if err != nil {
		log.Printf("Template error: %v", err)
		http.Error(w, "Template error", http.StatusInternalServerError)
		return
	}
	if err := t.Execute(w, data); err != nil {
		log.Printf("Error executing template: %v", err)
		http.Error(w, "Template execution error", http.StatusInternalServerError)
	}
}

func registerPage(w http.ResponseWriter, r *http.Request) {
	renderTemplate(w, "register", nil)
}

func loginPage(w http.ResponseWriter, r *http.Request) {
	renderTemplate(w, "login", nil)
}

func verifyPage(w http.ResponseWriter, r *http.Request) {
	session, _ := store.Get(r, "session")
	phone, ok := session.Values["phone"].(string)
	if !ok || phone == "" {
		renderTemplate(w, "login", map[string]interface{}{
			"error": "Session lost. Please login again.",
		})
		return
	}
	renderTemplate(w, "verify", map[string]interface{}{"phone": phone})
}

func successPage(w http.ResponseWriter, r *http.Request) {
	session, _ := store.Get(r, "session")
	auth, ok := session.Values["authenticated"].(bool)
	if !ok || !auth {
		http.Redirect(w, r, "/login", http.StatusSeeOther)
		return
	}
	renderTemplate(w, "success", map[string]interface{}{
		"message": "Login successful! Welcome to your dashboard.",
	})
}

func registerUser(w http.ResponseWriter, r *http.Request) {
	if r.Method != "POST" {
		http.Redirect(w, r, "/register", http.StatusSeeOther)
		return
	}
	fullname, phone, password := r.FormValue("fullname"), r.FormValue("phone"), r.FormValue("password")
	hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	stmt, err := db.Prepare("INSERT INTO reg_users (Fullname, Phone, Password) VALUES (?, ?, ?)")
	if err != nil {
		renderTemplate(w, "register", map[string]interface{}{"error": "Database error"})
		return
	}
	defer stmt.Close()
	_, err = stmt.Exec(fullname, phone, string(hashedPassword))
	if err != nil {
		renderTemplate(w, "register", map[string]interface{}{"error": "User already exists"})
		log.Print(err)
		return
	}
	renderTemplate(w, "login", map[string]interface{}{"success": "Registration successful! Please log in."})
}

func requestOTP(w http.ResponseWriter, r *http.Request) {
	if r.Method != "POST" {
		http.Redirect(w, r, "/login", http.StatusSeeOther)
		return
	}

	phone, password := r.FormValue("phone"), r.FormValue("password")
	var storedPassword string
	err := db.QueryRow("SELECT Password FROM reg_users WHERE Phone=?", phone).Scan(&storedPassword)
	if err != nil {
		renderTemplate(w, "login", map[string]interface{}{"error": "User not found"})
		return
	}
	if bcrypt.CompareHashAndPassword([]byte(storedPassword), []byte(password)) != nil {
		renderTemplate(w, "login", map[string]interface{}{"error": "Incorrect password"})
		return
	}

	otp, err := generateOTP()
	if err != nil {
		log.Printf("OTP generation failed: %v", err)
		return
	}

	saveOTP(phone, otp)
	sendOTP(phone, otp)

	session, _ := store.Get(r, "session")
	session.Values["phone"] = phone
	err = session.Save(r, w)
	if err != nil {
		renderTemplate(w, "login", map[string]interface{}{"error": "Incorrect password"})
		return
	}

	renderTemplate(w, "verify", map[string]interface{}{
		"success": "OTP sent successfully! Check your phone.",
	})
}

func verifyOTP(w http.ResponseWriter, r *http.Request) {
	if r.Method != "POST" {
		renderTemplate(w, "verify", nil)
		return
	}
	session, err := store.Get(r, "session")
	if err != nil {
		renderTemplate(w, "verify", map[string]interface{}{
			"error": "Could not store the user's phone number in session.",
		})
		log.Print(err)
		return
	}
	phone, ok := session.Values["phone"].(string)
	if !ok {
		renderTemplate(w, "verify", map[string]interface{}{
			"error": "Could not retrieve the user's phone number from the session.",
		})
		return
	}
	otp := r.FormValue("otp")

	mu.Lock()
	storedOTP, exists := otpStore[phone]
	expiry, existsExpiry := otpExpiry[phone]
	mu.Unlock()

	if !exists || !existsExpiry || time.Now().After(expiry) {
		renderTemplate(w, "verify", map[string]interface{}{
			"error": "OTP expired. Request a new one.",
		})
		return
	}

	if storedOTP != otp {
		renderTemplate(w, "verify", map[string]interface{}{
			"error": "Incorrect OTP. Try again.",
		})
		return
	}

	mu.Lock()
	delete(otpStore, phone)
	delete(otpExpiry, phone)
	mu.Unlock()

	session.Values["authenticated"] = true
	session.Save(r, w)

	http.Redirect(w, r, "/success", http.StatusSeeOther)
}

func generateOTP() (string, error) {
	var sb strings.Builder
	for i := 0; i < 6; i++ {
		num, err := rand.Int(rand.Reader, big.NewInt(10))
		if err != nil {
			return "", errors.New("failed to generate OTP")
		}
		sb.WriteString(fmt.Sprintf("%d", num))
	}
	return sb.String(), nil
}

func saveOTP(phone, otp string) {
	mu.Lock()
	otpStore[phone] = otp
	otpExpiry[phone] = time.Now().Add(5 * time.Minute)
	mu.Unlock()
}

func sendOTP(phone, otp string) {
	log.Printf("Sending OTP: %s to phone number: %s", otp, phone) // Log OTP to console

	client := twilio.NewRestClient()
	params := &v2010.CreateMessageParams{}
	params.SetTo(phone)
	params.SetFrom(os.Getenv("TWILIO_PHONE_NUMBER"))
	params.SetBody(fmt.Sprintf("Your OTP code is: %s", otp))

	resp, err := client.Api.CreateMessage(params)
	if err != nil {
		log.Printf("Twilio error: %v", err)
	} else {
		log.Printf("OTP %s sent to %s. SID: %s", otp, phone, *resp.Sid)
	}
}

func main() {
	router := mux.NewRouter()
	router.HandleFunc("/register", registerPage).Methods("GET")
	router.HandleFunc("/login", loginPage).Methods("GET")
	router.HandleFunc("/verify", verifyPage).Methods("GET")
	router.HandleFunc("/success", successPage).Methods("GET")

	router.HandleFunc("/register", registerUser).Methods("POST")
	router.HandleFunc("/request-otp", requestOTP).Methods("POST")
	router.HandleFunc("/verify-otp", verifyOTP).Methods("POST")

	log.Fatal(http.ListenAndServe(":8080", router))
}

The Go code above is used to build a web-based authentication system that uses MySQL for user storage and Twilio for OTP-based login verification. It starts by loading environment variables and initializing a MySQL database connection. The database is accessed using the go-sql-driver/mysql package, and sessions are managed with gorilla/sessions.

The system serves multiple web pages through HTML templates, including registration, login, verification, and a success page after authentication. It has HTTP handlers for user registration, where passwords are hashed using bcrypt before storing them in the database. When a user logs in, their credentials are validated against the stored hash. Instead of direct login, the system generates a six-digit OTP, stores it temporarily with a five-minute expiration, and sends it to the user via Twilio's messaging API.

Upon receiving the OTP, the user submits it for verification. If the OTP is correct and within its expiration time, the session is marked as authenticated, and the user is redirected to a success page. Otherwise, they are prompted to try again or request a new OTP. The application runs an HTTP server listening on port 8080, using gorilla/mux for routing, handling both GET and POST requests for authentication flows.

The OTP generation relies on the crypto/rand package for security, and a mutex (sync.Mutex) is used to synchronize access to the OTP store and expiration maps. If Twilio is not configured, OTPs are logged to the console. Error handling is integrated throughout to provide user-friendly messages and prevent database-related issues. The app structure is modular, ensuring each function handles a specific responsibility in the authentication process.

Create the application's templates

Now, create the application template for the register, login, and verify pages. To do that, go to the application's root directory and create a folder named templates. To create the register template, create a file named register.html and add the following code to it.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Register</title>
    <!-- Bootstrap CDN -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
    <style>
        .card {
            border-radius: 10px;
        }
        .form-control:focus {
            border-color: #007bff;
            box-shadow: 0 0 5px rgba(0, 123, 255, 0.5);
        }
    </style>
</head>
<body class="d-flex justify-content-center align-items-center vh-100 bg-light">
    <div class="container">
        <div class="row justify-content-center">
            <div class="col-md-6">
                <div class="card shadow-lg p-4">
                    <h2 class="text-center mb-4">User Registration</h2>
                    {{if .success}}
                    <div class="alert alert-success">{{.success}}</div>
                    {{end}}
                    {{if .error}}
                    <div class="alert alert-danger">{{.error}}</div>
                    {{end}}
                    <form action="/register" method="POST">
                        <div class="mb-3">
                            <label for="fullname" class="form-label">Full Name</label>
                            <input type="text" name="fullname" id="fullname" class="form-control" required>
                        </div>
                        <div class="mb-3">
                            <label for="phone" class="form-label">Phone</label>
                            <input type="tel" name="phone" id="phone" class="form-control" required >
                        </div>
                        <div class="mb-3">
                            <label for="password" class="form-label">Password</label>
                            <input type="password" name="password" id="password" class="form-control" required >
                        </div>
                        <button type="submit" class="btn btn-primary w-100">Register</button>
                    </form>
                    <p class="text-center mt-3">
                        Already have an account? <a href="/login">Login here</a>
                    </p>
                </div>
            </div>
        </div>
    </div>
    <!-- Bootstrap JS (Optional, for components like modals) -->
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

To create the login page template, create a new login.html file in the templates directory, and add the following code to it.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Login</title>
    <!-- Bootstrap CDN -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
    <style>
        .card {
            border-radius: 10px;
        }
        .form-control:focus {
            border-color: #007bff;
            box-shadow: 0 0 5px rgba(0, 123, 255, 0.5);
        }
    </style>
</head>
<body class="d-flex justify-content-center align-items-center vh-100 bg-light">
    <div class="container">
        <div class="row justify-content-center">
            <div class="col-md-6">
                <div class="card shadow-lg p-4">
                    <h2 class="text-center mb-4">Login with OTP</h2>
                    <!-- Display Success & Error Messages -->
                    {{if .success}}
                    <div class="alert alert-success alert-dismissible fade show" role="alert">
                        {{.success}}
                        <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
                    </div>
                    {{end}}
                    {{if .error}}
                    <div class="alert alert-danger alert-dismissible fade show" role="alert">
                        {{.error}}
                        <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
                    </div>
                    {{end}}
                    <!-- Login Form -->
                    <form action="/request-otp" method="POST">
                        <div class="mb-3">
                            <label for="phone" class="form-label">Phone</label>
                            <input type="tel" name="phone" id="phone" class="form-control" placeholder="Enter phone number" required>
                        </div>
                        <div class="mb-3">
                            <label for="password" class="form-label">Password</label>
                            <input type="password" name="password" id="password" class="form-control" placeholder="Enter password" required>
                        </div>
                        <button type="submit" class="btn btn-primary w-100">Request OTP</button>
                    </form>
                    <p class="text-center mt-3">
                        Don't have an account? <a href="/register">Register here</a>
                    </p>
                </div>
            </div>
        </div>
    </div>
    <!-- Bootstrap JS for interactive components -->
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

Now, for the verify page, create a new file named verify.html in the templates directory, and add the following code to it.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Verify OTP</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="d-flex justify-content-center align-items-center vh-100 bg-light">
    <div class="container">
        <div class="row justify-content-center">
            <div class="col-md-6">
                <div class="card shadow-lg p-4">
                    <h2 class="text-center mb-4">Enter OTP</h2>
                    {{if .error}}
                    <div class="alert alert-danger text-center">{{.error}}</div>
                    {{end}}
                    {{if .success}}
                    <div class="alert alert-success text-center">{{.success}}</div>
                    {{end}}
                    <form action="/verify-otp" method="POST" onsubmit="disableSubmit()">
                        <div class="mb-3">
                            <label for="otp" class="form-label">OTP Code</label>
                            <input type="text" name="otp" id="otp" class="form-control" required 
                                   title="Enter the 6-digit OTP you received">
                        </div>
                        <button type="submit" id="verifyBtn" class="btn btn-primary w-100">Verify</button>
                    </form>
                </div>
            </div>
        </div>
    </div>
    <script>
        function disableSubmit() {
            document.getElementById("verifyBtn").disabled = true;
        }
    </script>
</body>
</html>

Now, for the success page, create a new file named success.html in the templates directory, and add the following code to it.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Success - User Dashboard</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 600px;
            margin: 0 auto;
            padding: 20px;
            text-align: center;
            background-color: #f4f4f4;
        }
        .container {
            background-color: white;
            border-radius: 8px;
            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
            padding: 30px;
            margin-top: 50px;
        }
        .success-icon {
            color: #4CAF50;
            font-size: 48px;
            margin-bottom: 20px;
        }
        .message {
            color: #333;
            font-size: 18px;
            margin-bottom: 30px;
        }
        .dashboard-link {
            display: inline-block;
            background-color: #4CAF50;
            color: white;
            text-decoration: none;
            padding: 10px 20px;
            border-radius: 5px;
            transition: background-color 0.3s ease;
        }
        .dashboard-link:hover {
            background-color: #45a049;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="success-icon">✓</div>
        <h1>Login Successful</h1>
        <p class="message">{{ .message }}</p>
        <a href="#" class="dashboard-link">Go to Dashboard</a>
    </div>
</body>
</html>

Test the application

To test that the application works as expected. You have to start the application development server using the code below:

go run main.go

After starting the application, open http://localhost:8080/register in your preferred browser and register a new account, as shown in the image below.

User registration form on a web page with fields for full name, phone, and password, and a register button.

After registration, you will be redirected to the login dashboard. Log in using your phone number and password as shown in the image below.

Login screen with OTP option, phone and password fields, and a registration success message.

After the login is verified, you will receive an OTP to confirm your account login as shown in the screenshot below.

Otp sent to the phone screenshot

On the verification page, enter your OTP as shown in the screenshot below to verify your login. If your OTP is correct, a login successful message is displayed. Otherwise, the user is redirected back to the login page.

Web page prompting user to enter OTP code with a successful OTP sent message and a Verify button.

After you enter the OTP received and click on the verify button as shown above, it will then direct you to a success page as shown below.

Web browser displaying a Login Successful message with a green checkmark and a button labeled Go to Dashboard.

That's how to build an OTP login system with Twilio and Go

You’ve successfully built an OTP login system using Go and Twilio, learning how to generate OTPs, send them via Twilio, and verify them through a simple API. The next steps include using a database like Redis instead of storing OTPs in memory, implementing rate limiting to prevent brute-force attacks, and deploying the application using ngrok or a cloud provider. 🚀

This implementation includes user registration with a password, user login with OTP verification, a MySQL database to store user credentials and OTPs, Twilio OTP integration, rate-limiting for OTP requests, and brute-force protection for OTP verification.

David Fagbuyiro is a software engineer and technical writer who is passionate about extending his knowledge to developers through technical writing. You can find him on LinkedIn.

OTP icon in the main image was created by Freepik  on Flaticon.