How to Implement OTP Authentication in Rust with Twilio

December 09, 2025
Written by
Popoola Temitope
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

How to Implement OTP Authentication in Rust with Twilio

When building a secure user authentication system, traditional username-password methods are often inadequate. Incorporating One-Time Passwords (OTPs) adds an extra layer of security by enhancing user verification and helping to prevent unauthorized access.

In this tutorial, you'll learn how to implement OTP-based authentication in a Rust application using Twilio Verify to send one-time codes for user login verification.

Prerequisites

To follow along with this tutorial, you should have the following:

Create a new Rust project

To get started, let’s create a new Rust project using Cargo. Open your terminal, navigate to the folder where you keep your Rust projects, and run the following command:

cargo new rust_otp_twilio
cd rust_otp_twilio

The code above initializes a Rust project. Once it's done, open the project folder in your preferred IDE or code editor.

Add the application's dependencies

Let’s add the necessary dependencies for the application. To do this, open the Cargo.toml file located in the root directory of the project and add the following code in the [dependencies] table.

actix-web = "4.4"
actix-session = { version = "0.8", features = ["cookie-session"] }
bcrypt = "0.15"
chrono = { version = "0.4", features = ["serde"] }
dotenv = "0.15"
env_logger = "0.10"
rand = "0.8"
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.140"
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "mysql", "chrono"] }
tera = "1.19"
tokio = { version = "1", features = ["full"] }

In the dependencies above, we have:

  • actix-web: A fast web framework for building APIs and web applications
  • actix-session: Handles user sessions using cookie-based storage
  • bcrypt: Provides secure password hashing
  • chrono: Manages dates and times; integrates with Serde
  • dotenv: Loads environment variables from a .env file
  • env_logger: Enables logging, useful for debugging and monitoring
  • rand: Generates random values for OTP tokens
  • reqwest: An HTTP client with built-in JSON support
  • serde: For serializing and deserializing data (e.g., JSON)
  • sqlx: An asynchronous database library with MySQL support
  • tera: A template engine for rendering HTML views
  • tokio: The async runtime used by sqlx and other async libraries

Create the database schema

Next, let's create a database schema to store user information. To do this, log in to your MySQL server, create a new database named "twilio_otp_auth", and run the SQL code below in your MySQL client to create the "users" table.

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    fullname VARCHAR(255) NOT NULL,
    phone VARCHAR(20) UNIQUE NOT NULL,
    password VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Set up the environment variables

To store the application credentials as environment variables, keeping them out of the code, create a file named .env in the root directory of the project and add the following variables to it.

DATABASE_URL=mysql://<db_username>:<db_password>@localhost:3306/twilio_otp_auth
TWILIO_ACCOUNT_SID=<twilio_account_sid>
TWILIO_AUTH_TOKEN=<twilio_auth_token>
TWILIO_VERIFY_SERVICE_SID=<twilio_service_sid>

Then, in the code above, replace the <db_username> and <db_password> placeholders with your MySQL database's username and password, respectively.

Retrieve your Twilio credentials

To retrieve your Twilio access credentials, log in to your Twilio Console dashboard. You’ll find your Account SID and Auth Token under the Account Info section, as shown in the screenshot below.

Twilio dashboard displaying account SID, auth token, and verification resources section.

In the .env file, replace the <twilio_account_sid> and <twilio_auth_token> placeholders with your Twilio Account SID and Auth Token, respectively.

Create a verification service

Next, you need to create a Twilio Verification Service, which provides a secure and easy way to create and verify OTPs. To do that, from the Twilio Console, navigate to Explore Products > User Authentication & Identity > Verify.

Twilio dashboard showing the creation of a new service with options for SMS, WhatsApp, Email, and Voice verification.

There, click Create new and fill out the form as follows:

  • Friendly Name: Enter your service name
  • Authorize the use of Friendly Name: Check this option
  • Verification Channels: Enable "SMS"

Then, click the Continue button to create the service. You’ll be redirected to the Service settings page, as shown in the screenshot below.

Screenshot of the service settings page for RUST APP verification, highlighting the Service SID field.

Now, copy the Service SID and replace the <twilio_service_sid> placeholder in .env with it.

Establish the database connection

Now, let’s connect the application to the MySQL server. To do this, navigate to the src folder, create a new file named db.rs, and add the following code to the file.

use sqlx::{mysql::MySqlPoolOptions, MySql, Pool};
use std::env;
pub async fn get_db_pool() -> Pool<MySql> {
    let url = env::var("DATABASE_URL").expect("DATABASE_URL not set");
    MySqlPoolOptions::new()
        .max_connections(5)
        .connect(&url)
        .await
        .expect("Failed to connect to DB")
}

Create the data models

Let’s now define the application’s data models to specify how data is structured and managed in a safe and predictable way. To do this, create a new file named models.rs inside the src folder and add the following code to it:

use serde::Deserialize;
#[derive(sqlx::FromRow, Debug)]
pub struct User {
    pub id: i32,
    pub fullname: String,
    #[allow(dead_code)]
    pub phone: String,
    pub password: String,
}
#[derive(Deserialize)]
pub struct RegisterRequest {
    pub fullname: String,
    pub phone: String,
    pub password: String,
}
#[derive(Deserialize)]
pub struct LoginRequest {
    pub phone: String,
    pub password: String,
}
#[derive(Deserialize)]
pub struct OtpRequest {
    pub phone: String,
    pub code: String,
}

Create the handler functions

Now, let’s create the application's handler functions, which contain the core logic — such as processing form data, storing and retrieving user information from the database, sending OTPs to users, and more. To do this, create a new file named handlers.rs inside the src folder and add the following code to it:

use actix_web::{get, post, web, HttpResponse, Responder, Result};
use actix_web::http::header::ContentType;
use actix_session::Session;
use sqlx::MySqlPool;
use tera::Tera;
use crate::models::*;
use reqwest::Client;
use serde_json::{Value};
use std::env;
pub fn init_routes(cfg: &mut web::ServiceConfig) {
    cfg.service(register_form)
        .service(register)
        .service(login_form)
        .service(login)
        .service(verify_form)
        .service(verify)
        .service(profile)
        .service(logout);
}
#[get("/register")]
async fn register_form(tmpl: web::Data<Tera>) -> Result<impl Responder> {
    let ctx = tera::Context::new();
    match tmpl.render("register.html", &ctx) {
        Ok(rendered) => Ok(HttpResponse::Ok()
            .content_type(ContentType::html())
            .body(rendered)),
        Err(e) => {
            eprintln!("Template error: {}", e);
            Ok(HttpResponse::InternalServerError().body("Template error"))
        }
    }
}
#[post("/register")]
async fn register(
    pool: web::Data<MySqlPool>, 
    tmpl: web::Data<Tera>,
    form: web::Form<RegisterRequest>
) -> Result<impl Responder> {
    let mut ctx = tera::Context::new();
    let hashed_password = match bcrypt::hash(&form.password, bcrypt::DEFAULT_COST) {
        Ok(hash) => hash,
        Err(_) => {
            ctx.insert("error", "Password hashing failed");
            let rendered = tmpl.render("register.html", &ctx).unwrap();
            return Ok(HttpResponse::Ok().content_type(ContentType::html()).body(rendered));
        }
    };
    match sqlx::query!(
        "INSERT INTO users (fullname, phone, password) VALUES (?, ?, ?)",
        form.fullname,
        form.phone,
        hashed_password
    ).execute(pool.get_ref()).await {
        Ok(_) => {
            ctx.insert("success", "Account created successfully! You can now login.");
            let rendered = tmpl.render("login.html", &ctx).unwrap();
            Ok(HttpResponse::Ok().content_type(ContentType::html()).body(rendered))
        },
        Err(e) => {
            eprintln!("Database error: {}", e);
            ctx.insert("error", "Registration failed - phone may already exist");
            let rendered = tmpl.render("register.html", &ctx).unwrap();
            Ok(HttpResponse::Ok().content_type(ContentType::html()).body(rendered))
        }
    }
}
#[get("/login")]
async fn login_form(tmpl: web::Data<Tera>) -> Result<impl Responder> {
    let ctx = tera::Context::new();
    match tmpl.render("login.html", &ctx) {
        Ok(rendered) => Ok(HttpResponse::Ok()
            .content_type(ContentType::html())
            .body(rendered)),
        Err(e) => {
            eprintln!("Template error: {}", e);
            Ok(HttpResponse::InternalServerError().body("Template error"))
        }
    }
}
#[post("/login")]
async fn login(
    pool: web::Data<MySqlPool>,
    tmpl: web::Data<Tera>,
    form: web::Form<LoginRequest>,
) -> Result<impl Responder> {
    let user_result = sqlx::query_as!(
        User,
        "SELECT id, fullname, phone, password FROM users WHERE phone = ?",
        form.phone
    ).fetch_optional(pool.get_ref()).await;
    let mut ctx = tera::Context::new();
    ctx.insert("phone", &form.phone);
    match user_result {
        Ok(Some(user)) => {
            if bcrypt::verify(&form.password, &user.password).unwrap_or(false) {
                match start_verification(&form.phone).await {
                    Ok(_) => {
                        let rendered = tmpl.render("verify.html", &ctx).unwrap();
                        return Ok(HttpResponse::Ok().content_type(ContentType::html()).body(rendered));
                    }
                    Err(e) => {
                        eprintln!("Twilio Verify error: {}", e);
                        ctx.insert("error", "Failed to send verification code");
                    }
                }
            } else {
                ctx.insert("error", "Invalid phone or password");
            }
        }
        Ok(None) => {
            ctx.insert("error", "Invalid phone or password");
        }
        Err(e) => {
            eprintln!("Database error: {}", e);
            ctx.insert("error", "Database error occurred");
        }
    }
    let rendered = tmpl.render("login.html", &ctx).unwrap();
    Ok(HttpResponse::Ok().content_type(ContentType::html()).body(rendered))
}
#[get("/verify")]
async fn verify_form(tmpl: web::Data<Tera>) -> Result<impl Responder> {
    let ctx = tera::Context::new();
    match tmpl.render("verify.html", &ctx) {
        Ok(rendered) => Ok(HttpResponse::Ok()
            .content_type(ContentType::html())
            .body(rendered)),
        Err(e) => {
            eprintln!("Template error: {}", e);
            Ok(HttpResponse::InternalServerError().body("Template error"))
        }
    }
}
#[post("/verify")]
async fn verify(
    pool: web::Data<MySqlPool>,
    tmpl: web::Data<Tera>,
    session: Session,
    form: web::Form<OtpRequest>,
) -> Result<impl Responder> {
    let user_result = sqlx::query_as!(
        User,
        "SELECT id, fullname, phone, password FROM users WHERE phone = ?",
        form.phone
    ).fetch_optional(pool.get_ref()).await;
    let mut ctx = tera::Context::new();
    ctx.insert("phone", &form.phone);
    match user_result {
        Ok(Some(user)) => {
            match check_verification(&form.phone, &form.code).await {
                Ok(true) => {
                    if let Err(e) = session.insert("user_id", user.id) {
                        eprintln!("Session error: {}", e);
                        ctx.insert("error", "Session error occurred");
                        let rendered = tmpl.render("verify.html", &ctx).unwrap();
                        return Ok(HttpResponse::Ok().content_type(ContentType::html()).body(rendered));
                    }
                    if let Err(e) = session.insert("fullname", &user.fullname) {
                        eprintln!("Session error: {}", e);
                        ctx.insert("error", "Session error occurred");
                        let rendered = tmpl.render("verify.html", &ctx).unwrap();
                        return Ok(HttpResponse::Ok().content_type(ContentType::html()).body(rendered));
                    }
                    return Ok(HttpResponse::SeeOther()
                        .insert_header(("Location", "/profile"))
                        .finish());
                }
                Ok(false) => {
                    ctx.insert("error", "Invalid or expired verification code");
                }
                Err(e) => {
                    eprintln!("Twilio Verify error: {}", e);
                    ctx.insert("error", "Verification failed");
                }
            }
        }
        Ok(None) => {
            ctx.insert("error", "User not found");
        }
        Err(e) => {
            eprintln!("Database error: {}", e);
            ctx.insert("error", "Database error occurred");
        }
    }
    let rendered = tmpl.render("verify.html", &ctx).unwrap();
    Ok(HttpResponse::Ok().content_type(ContentType::html()).body(rendered))
}
#[get("/profile")]
async fn profile(
    session: Session,
    tmpl: web::Data<Tera>
) -> Result<impl Responder> {
    match session.get::<String>("fullname") {
        Ok(Some(fullname)) => {
            let mut ctx = tera::Context::new();
            ctx.insert("fullname", &fullname);
            match tmpl.render("profile.html", &ctx) {
                Ok(rendered) => Ok(HttpResponse::Ok()
                    .content_type(ContentType::html())
                    .body(rendered)),
                Err(e) => {
                    eprintln!("Template error: {}", e);
                    Ok(HttpResponse::InternalServerError().body("Template error"))
                }
            }
        }
        Ok(None) => {
            Ok(HttpResponse::SeeOther()
                .insert_header(("Location", "/login"))
                .finish())
        }
        Err(e) => {
            eprintln!("Session error: {}", e);
            Ok(HttpResponse::InternalServerError().body("Session error"))
        }
    }
}
#[get("/logout")]
async fn logout(session: Session) -> Result<impl Responder> {
    session.purge();
    Ok(HttpResponse::SeeOther()
        .insert_header(("Location", "/login"))
        .finish())
}
async fn start_verification(phone: &str) -> Result<(), Box<dyn std::error::Error>> {
    let sid = env::var("TWILIO_ACCOUNT_SID")?;
    let token = env::var("TWILIO_AUTH_TOKEN")?;
    let service_sid = env::var("TWILIO_VERIFY_SERVICE_SID")?;
    let client = Client::new();
    let url = format!("https://verify.twilio.com/v2/Services/{}/Verifications", service_sid);
    let params = [
        ("To", phone),
        ("Channel", "sms"),
    ];
    let response = client
        .post(&url)
        .basic_auth(&sid, Some(&token))
        .form(&params)
        .send()
        .await?;
    if !response.status().is_success() {
        let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
        return Err(format!("Twilio Verify API error: {}", error_text).into());
    }
    Ok(())
}
async fn check_verification(phone: &str, code: &str) -> Result<bool, Box<dyn std::error::Error>> {
    let sid = env::var("TWILIO_ACCOUNT_SID")?;
    let token = env::var("TWILIO_AUTH_TOKEN")?;
    let service_sid = env::var("TWILIO_VERIFY_SERVICE_SID")?;
    let client = Client::new();
    let url = format!("https://verify.twilio.com/v2/Services/{}/VerificationCheck", service_sid);
    let params = [
        ("To", phone),
        ("Code", code),
    ];
    let response = client
        .post(&url)
        .basic_auth(&sid, Some(&token))
        .form(&params)
        .send()
        .await?;
    if response.status().is_success() {
        let json_response: Value = response.json().await?;
        if let Some(status) = json_response.get("status") {
            return Ok(status == "approved");
        }
    } else {
        let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
        eprintln!("Twilio Verify check error: {}", error_text);
    }
    Ok(false)
}

From the code above:

  • The register_form() function displays the registration page template, while the register() function processes the registration form data, hashes the password, and stores the user in the database
  • The login_form() function displays the login page template, while the login() function authenticates the user by verifying the password, and initiates OTP verification using the start_verification() function
  • The verify_form() function displays the OTP verification form, while the verify() function checks the OTP using the check_verification() function. On success, it logs the user in by storing session data, and redirects them to the profile page
  • The profile() function displays the user's profile if they are authenticated, while the logout() function clears the user session, and redirects to the login page

Create the application entry point

Next, let’s create the application’s entry point that brings all the components together. To do this, open the main.rs file inside the src folder, and replace its contents with the following code.

mod db;
mod handlers;
mod models;
use actix_web::{middleware::Logger, web, App, HttpServer};
use actix_session::{SessionMiddleware, storage::CookieSessionStore};
use actix_web::cookie::Key;
use tera::Tera;
use dotenv::dotenv;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
    dotenv().ok();
    env_logger::init();
    let pool = db::get_db_pool().await;
    let tera = match Tera::new("templates/**/*") {
        Ok(t) => t,
        Err(e) => {
            eprintln!("Template parsing error: {}", e);
            std::process::exit(1);
        }
    };
    let secret_key = Key::generate();
    println!("Starting server at http://127.0.0.1:8080");
    HttpServer::new(move || {
        App::new()
            .wrap(Logger::default())
            .wrap(
                SessionMiddleware::builder(CookieSessionStore::default(), secret_key.clone())
                    .cookie_secure(false) 
                    .build()
            )
            .app_data(web::Data::new(pool.clone()))
            .app_data(web::Data::new(tera.clone()))
            .configure(handlers::init_routes)
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

The code above initializes and runs an Actix Web server that loads environment variables, connects to the database, configures Tera templates, manages user sessions with cookie-based middleware, and registers HTTP routes.

Create the application template

Let’s create the user interface templates for the registration, login, verification, and profile pages. To do this, from the project's root directory, create a new folder named templates and create the following files inside it:

  • register.html
  • login.html
  • verify.html
  • profile.html

Inside the register.html file, add the following code.

<!DOCTYPE html>
<html>
<head>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="container mt-5">
  <h2>Register</h2>
  {% if error %}
    <div class="alert alert-danger">{{ error }}</div>
  {% endif %}
  <form method="post" action="/register" class="mt-3">
    <div class="mb-3">
      <label class="form-label">Fullname</label>
      <input type="text" name="fullname" class="form-control" required>
    </div>
    <div class="mb-3">
      <label class="form-label">Phone</label>
      <input type="text" name="phone" class="form-control" required>
    </div>
    <div class="mb-3">
      <label class="form-label">Password</label>
      <input type="password" name="password" class="form-control" required>
    </div>
    <button type="submit" class="btn btn-success">Register</button>
  </form>
  <div class="mt-3">
    <p>Already have an account? <a href="/login">Login here</a></p>
  </div>
</body>
</html>

Inside the login.html file, add the following code:

<!DOCTYPE html>
<html>
<head>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="container mt-5">
  <h2>Login</h2>
  {% if success %}
    <div class="alert alert-success">{{ success }}</div>
  {% endif %}
  {% if error %}
    <div class="alert alert-danger">{{ error }}</div>
  {% endif %}
  <form method="post" action="/login" class="mt-3">
    <div class="mb-3">
      <label class="form-label">Phone</label>
      <input type="text" name="phone" class="form-control" required>
    </div>
    <div class="mb-3">
      <label class="form-label">Password</label>
      <input type="password" name="password" class="form-control" required>
    </div>
    <button type="submit" class="btn btn-primary">Login</button>
  </form>
  <div class="mt-3">
    <p>Don't have an account? <a href="/register">Register here</a></p>
  </div>
</body>
</html>

Inside the verify.html file, add the following code.

<!DOCTYPE html>
<html>
<head>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="container mt-5">
  <h2>Verify OTP</h2>
  {% if error %}
    <div class="alert alert-danger">{{ error }}</div>
  {% endif %}
  <form method="post" action="/verify" class="mt-3">
    <input type="hidden" name="phone" value="{{ phone }}">
    <div class="mb-3">
      <label class="form-label">Code</label>
      <input type="text" name="code" class="form-control" required>
    </div>
    <button type="submit" class="btn btn-warning">Verify</button>
  </form>
</body>
</html>

Inside the profile.html file, add the following code:

<!DOCTYPE html>
<html>
<head>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="container mt-5">
  <div class="d-flex justify-content-between align-items-center mb-4">
    <h1 class="text-success">Welcome {{ fullname }}!</h1>
    <a href="/logout" class="btn btn-outline-danger">Logout</a>
  </div>
  <div class="card">
    <div class="card-body">
      <h5 class="card-title">Profile Information</h5>
      <p class="card-text"><strong>Full Name:</strong> {{ fullname }}</p>
      <p class="card-text text-muted">You have successfully logged in and verified your account with OTP.</p>
    </div>
  </div>
</body>
</html>

Test the application works as expected

To start the application server, run the command below.

cargo run

Then, to test the application, open http://localhost:8080/register in your preferred browser and create an account, as shown in the screenshot below.

A registration form with fields for fullname, phone, and password, and buttons to register or login.

After creating an account, you will be redirected to the login page to sign in, as shown in the screenshot below.

Login page with a success message indicating the account was created, fields for phone and password, and a login button.

After your phone number and password are validated, you will be redirected to the login verification page, as shown in the screenshot below.

Web page prompting user to verify OTP with input box and yellow verify button

Next, enter your OTP and click the Verify button. If the OTP is correct, you will be taken to your profile page, as shown in the screenshot below.

Screenshot of a user profile page showing a welcome message for Matthew and confirmation of OTP verification.

That’s how to Implement OTP authentication in Rust with Twilio

In this tutorial, you’ve learned how to implement a secure user authentication system in a Rust application using Twilio Verify. This system acts as a form of Two-factor Authentication (2FA), enhancing security by requiring users to verify their identity with a One-time Password (OTP) delivered via SMS.

Popoola Temitope is a mobile developer and a technical writer who loves writing about frontend technologies. He can be reached on LinkedIn.