How to Build Passwordless Auth Using TOTP With Twilio Verify in Python

August 13, 2026
Written by

Typing or generating a unique password for every new account or website is a hassle. Although password managers help, passwordless auth (authentication) offers a more streamlined and secure alternative.

In this tutorial, you will learn about passwordless auth, and build a Python app that uses Twilio Verify to implement it.

Prerequisites

Before you begin, ensure you have the following:

  • A free Twilio account. Click here to create a free account if you are new to Twilio.
  • An authentication app, such as Twilio Authy
  • Python (ideally, version 3.12 or above)
  • Your favourite text editor or IDE (such as neovim or Visual Studio Code)
  • Your favourite web browser

Architecture

This application will be a simplistic web-based application, split up over three stages.

  • Stage one: the user will submit their username to begin setting up Two-factor Authentication (2FA).
  • Stage two: they'll set up a TOTP entry in their authenticator app. They'll do this with their authenticator app by scanning a QR code or entering a unique code. Then, they'll submit the initial code that their authenticator app generates for them. If the code is valid, their account is ready to use.
  • Stage three: They can validate future codes which their authenticator app generates for them in the final form in the application.

What is passwordless auth?

Passwordless auth is an authentication method where the user does not need a password in order to log into an app or system. Rather, the user's mobile device receives a one-time code. In this authentication method, users are authenticated using other unique and more secure alternatives like Time-based One-time Passwords (TOTP, or Soft Token), SMS, Passkeys, Silent Network Authentication (SNA), Voice, or email notification.

Here's a breakdown of how it works, using TOTP:

  1. User initiation: When a user creates an account, instead of entering a password, they create a new Factor resource with Twilio Verify — initially marked as unverified — seeding it with a unique (auto-generated) identifier.
  2. Scan the QR code with an authenticator app: Using an authenticator app such as Twilio Authy, they scan the QR code then enter the code that the authenticator app provides. The code is validated using Twilio Verify. If the code is valid the Factor is marked verified. The authenticator app can now provide time-based codes to use in the future, when logging in, which Twilio Verify will verify.
  3. Code validation: When the user logs in, they enter their username and a TOTP code generated by their authenticator app. The application then uses Twilio Verify to validate the code along with their unique identifier.

This approach significantly enhances security by leveraging the inherent security features of the user's mobile device as a second factor in the authentication process. It also eliminates the need for them to memorize passwords, thereby simplifying the authentication process and enhancing user convenience.

Passwordless authentication has additional advantages, including the following:

  • Enhanced security: Reduces the risk of password-related breaches.
  • Convenience: Eliminates the need for users to remember and manage multiple passwords.
  • Reduced friction: Streamlines the login process, improving the user experience.
  • Scalability: Easily scalable with Twilio's infrastructure.

Build the app

Step 1: Set up the project

Set up a new project by creating and entering a new directory, then initializing a virtual environment. Run the following commands where you store your Python projects:

mkdir twilio-verify-totp-python
cd twilio-verify-totp-python
mkdir -p templates static/css
python3 -m venv .venv
source .venv/bin/activate

Step 2: Install the required dependencies

Next, create a new file named requirements.txt in the project's top-level directory and add the following content to it:

fastapi>=0.141.1
uvicorn[standard]>=0.52.2
python-multipart>=0.0.32
jinja2>=3.1.6
itsdangerous>=2.2.0
twilio>=9.11.0
python-dotenv>=1.2.2
qrcode[pil]>=8.2

Then install the dependencies by running:

pip install -r requirements.txt

In case you're not familiar with them, here's a short description of the dependencies that you just installed:

  • FastAPI: A modern, high-performance web framework for building APIs and web applications with Python
  • Uvicorn: An ASGI server for running FastAPI applications
  • python-multipart: Required by FastAPI to parse HTML form data
  • Jinja2: A templating engine for rendering HTML templates, included as an optional FastAPI dependency
  • itsdangerous: Required by Starlette's session middleware to cryptographically sign session cookies; not bundled automatically
  • Twilio's Python Helper Library: This simplifies integrating with Twilio in Python
  • python-dotenv: Loads environment variables from .env files into os.environ
  • qrcode: This simplifies generating QR codes in the application. The [pil] extra includes Pillow, which provides the image rendering backend.

Now, open the project directory in your preferred text editor or IDE.

Step 3: Set the required environment variables

Dotenv files (commonly named .env) are used to store the configuration information that your app needs during development, separate from the application's code. For this tutorial, it will be your Twilio credentials (i.e., your Twilio Account SID and Auth Token) and a Verify Service SID.

In your project's top-level directory, create a new file named .env and add the following content:

TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
TWILIO_VERIFY_SERVICE_SID=

You next need to retrieve the credentials to set as their values. To do that, sign into the Twilio Console. There, click the black and white up arrow at the bottom of the page, and you should see your Account SID and Auth Token in the Workbench; as shown in the image below.

Workbench interface showing API credentials, quick actions, and navigation tabs for Overview, Debugger, and Alarms.

Copy these values and paste them into .env as the values for TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN.

Next, navigate to Products and Services > Verify > Services. On this page, click Create new. Then, fill out the initial form with the configuration values shown in the screenshot below, and click Continue.

Twilio interface showing options to create a new service with SMS, WhatsApp, Email, and Voice verification channels.

In the next step, leave Enable Fraud Guard set to "Yes" and click Continue to finish creating the service.

Dialog box for enabling Fraud Guard with yes or no option in a software interface.

After creating the service, copy the Service SID and paste it into .env as the value of TWILIO_VERIFY_SERVICE_SID.

Screenshot of Twilio service settings interface showing general settings with service SID and branded sender ID.

Step 4: Build the base Python application

The next thing to do is to create the application. Create a new file named app.py in the project's top-level directory and add the following code to it:

import base64
import os
import secrets
from io import BytesIO
from pathlib import Path
import qrcode
from dotenv import load_dotenv
from fastapi import FastAPI, Form, Request
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from twilio.rest import Client

load_dotenv()

TWILIO_ACCOUNT_SID = os.environ["TWILIO_ACCOUNT_SID"]
TWILIO_AUTH_TOKEN = os.environ["TWILIO_AUTH_TOKEN"]
TWILIO_VERIFY_SERVICE_SID = os.environ["TWILIO_VERIFY_SERVICE_SID"]

BASE_DIR = Path(__file__).parent
app = FastAPI()
app.add_middleware(
    SessionMiddleware,
    secret_key=os.getenv("SECRET_KEY", secrets.token_hex(32)),
)
app.mount("/css", StaticFiles(directory=BASE_DIR / "static" / "css"), name="css")
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
twilio_client = Client(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN)

The code above loads environment variables from .env using python-dotenv, then reads the three required Twilio credentials directly from os.environ, which raises a KeyError at startup if any are missing.

It then creates a FastAPI application instance and attaches three things to it:

  • SessionMiddleware: Stores session data in signed cookies using itsdangerous. A SECRET_KEY from the environment is used as the signing key; if one is not set, a random key is generated.
  • StaticFiles: Serves the contents of static/css/ at the /css/ URL path, which is where the browser expects the stylesheet.
  • Jinja2Templates: Configures Jinja2 to load templates from the templates/ directory.

Finally, a Twilio REST Client is initialized with the account credentials. All of this is module-level code, so it runs once at startup.

Step 5: Add the ability to create a new TOTP Factor

Now, you'll add the first feature of the application: the ability to create a new TOTP Factor. Add the following function to app.py:

@app.get("/") 
async def display_create_totp_factor_form(request: Request): 
    return templates.TemplateResponse(request, "enter-username.html")

The display_create_totp_factor_form() function uses Jinja2 to render templates/enter-username.html. The @app.get("/") decorator registers it to handle GET requests to the application's default route, rendering a form for the user to enter their username as the first stage in the TOTP setup process.

Now, in the templates directory, create a new file named enter-username.html, and in that file, paste the code below:

<!DOCTYPE html>
<html>
<head>
    <title>Register New TOTP Factor</title>
    <link href="https://assets.twilio.com/public_assets/paste-fonts/1.5.2/fonts.css" rel="stylesheet">
    <link href="/css/styles.css" rel="stylesheet">
</head>
<body class="container">
    <main>
        <h1>Register New TOTP Factor</h1>
        <p class="mt-60">
        This demo of the <a href="https://www.twilio.com/docs/verify/api">Twilio Verify API</a> shows how to set up two-factor authentication (2FA) with a third-party authenticator app like <a href="https://authy.com/download/">Authy</a> or Google Authenticator using the <a href="https://www.twilio.com/docs/glossary/totp">time-based one-time password (TOTP)</a> standard.
        </p>
        <hr class="mt-60">
        <form action="/" method="post" class="mt-60 mb-60">
            <label for="username">Enter your username:</label>
            <div class="mt-20" style="display: flex; flex: auto; column-gap: 0.25rem; flex-shrink: 0;">
                <input
                    id="username"
                    inputmode="text"
                    name="username"
                    required
                    style="flex-shrink: 1;"
                    type="text"
                >
                <button
                    class="primary"
                    style="flex-grow: 1; white-space: nowrap"
                    type="submit"
                >Set up two-factor authentication</button>
            </div>
        </form>
        <div style="background-color: #d6ebf0; padding: 2em; border-radius: 0.25rem;" class="mb-60 mt-60">
            <h2 class="mb-60">Create a New Factor | What's happening here:</h2>
            <p style="line-height: 1.5;" class="mb-60">
                The API is <strong>creating a new TOTP <a href="https://www.twilio.com/docs/verify/api/factor#create-a-new-factor-resource">Factor</a></strong> when you click "Set up two-factor authentication".
                This is how the Verify API connects a user (<code>identity</code>), the <a href="https://www.twilio.com/docs/glossary/totp">TOTP channel</a>, and your app.
                Each Factor returns the secret seed and URI used to create a QR code.
            </p>
            <p>
                For this demo, we're asking for a username that will be displayed in the account name for the authenticator app.
            </p>
        </div>
        <a href="/">Reset</a>
    </main>
</body>
</html>

As you can see from the HTML above, it is a simplistic HTML page with a form, with a single field named "username". Note that the field's type and inputmode attributes are set to "text". This hints to browsers on mobile devices to render a virtual keyboard most appropriate for entering text input.

Then, back in app.py, add the following function after display_create_totp_factor_form().

@app.post("/")
async def process_create_totp_factor_form(request: Request, username: str = Form(...)):
    seed = secrets.token_hex(16)
    request.session["seed"] = seed
    factor = (
        twilio_client.verify.v2
        .services(TWILIO_VERIFY_SERVICE_SID)
        .entities(seed)
        .new_factors
        .create(friendly_name=username, factor_type="totp")
    )
    request.session["friendly_name"] = factor.friendly_name
    request.session["sid"] = factor.sid
    request.session["otp_uri"] = factor.binding["uri"]
    request.session["url"] = factor.url
    if factor.status == "unverified":
        return RedirectResponse("/challenge", status_code=302)
    return RedirectResponse("/", status_code=302)

The process_create_totp_factor_form() function creates a new TOTP Factor with Twilio Verify. The factor is seeded by a string created with secrets.token_hex(), which generates a cryptographically secure 32-character hex string. This is kept within the 8–64 character limit and is stored in the session for later use.

Following this, it's used to create a new factor resource using the Twilio Rest Client. From the response to that request the following properties are stored in session for later use, the:

  • Friendly Name: The Factor's friendly name
  • SID: A 34 character string that uniquely identifies the Factor.
  • OTP URI: This stores the OTP configuration, including a shared secret and related parameters, for the OTP client (e.g., Authy) to use to generate the initial TOTP code.
  • URL: The URL of the Factor resource.

Then, if the new Factor's status is set to "unverified", the user is redirected to the /challenge route. Otherwise, they're redirected back to / to try again.

FastAPI reads the username field directly from the POST form body via Form(...). Unlike Slim, routes are registered by the @app.get and @app.post decorators on each handler function, so there is no separate routing table to maintain.

Step 6: Add the ability to verify the TOTP Factor

You'll now add the functionality to render the form with the QR code for verifying the new Factor resource. First, add the following helper function to app.py, before the route handlers:

def make_qr_code(data: str) -> str:
    qr = qrcode.QRCode()
    qr.add_data(data)
    qr.make(fit=True)
    img = qr.make_image(fill_color="black", back_color="white")
    buf = BytesIO()
    img.save(buf, format="PNG")
    return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()

make_qr_code() generates a QR code image from a string and returns it as a base64-encoded PNG data URI. The template can embed this directly in an tag's src attribute without needing a separate image endpoint.

Now, add the following route handler to app.py:

@app.get("/challenge")
async def display_verify_user_form(request: Request):
    otp_uri = request.session.get("otp_uri", "")
    seed = request.session.get("seed", "")
    return templates.TemplateResponse(
        request,
        "verify-user.html",
        {
            "qr_code": make_qr_code(otp_uri) if otp_uri else "",
            "seed": seed,
        },
    )

The display_verify_user_form() function renders templates/verify-user.html, providing the form for the user to enter to validate their new TOTP Factor. The template receives two variables:

  • A QR code which embeds the unverified Factor's OTP URI
  • The unverified Factor's seed or identity

With that done, in the templates directory create a new file named verify-user.html and paste the code below into the file.

<!DOCTYPE html>
<html>
<head>
    <title>Verify New TOTP Factor</title>
    <link href="https://assets.twilio.com/public_assets/paste-fonts/1.5.2/fonts.css" rel="stylesheet">
    <link href="/css/styles.css" rel="stylesheet">
</head>
<body class="container">
    <main>
        <h1>Verify New TOTP Factor</h1>
        <p class="mt-60 mb-60">
        This demo of the <a href="https://www.twilio.com/docs/verify/api">Twilio Verify API</a> shows how to set up two-factor authentication (2FA) with a third-party authenticator app like <a href="https://authy.com/download/">Authy</a> or Google Authenticator using the <a href="https://www.twilio.com/docs/glossary/totp">time-based one-time password (TOTP)</a> standard.
        </p>
        <hr>
        <form action="/challenge" method="post" class="mt-60">
            <p>Please scan the QR code in an authenticator app like Authy.</p>
            <div class="mt-20 mb-20" style="display: flex; flex: auto; column-gap: 0.25rem; flex-shrink: 0;">
                <img src="{{ qr_code }}" alt="QR Code" width="40%">
                <div class="mt-60">
                    <p>Or enter this code into your authentication app:</p>
                    <p><strong>{{ seed }}</strong></p>
                </div>
            </div>
            <label for="code">Enter the code generate by your authenticator app to verify this factor:</label>
            <div class="mt-20" style="display: flex; flex: auto; column-gap: 0.25rem; flex-shrink: 0;">
                <input
                    id="code"
                    inputmode="numeric"
                    name="code"
                    required
                    pattern="\d{6}"
                    placeholder="123456"
                    type="text"
                >
                <button type="submit" class="primary" style="flex-grow: 1;">Verify</button>
            </div>
        </form>
        <div style="background-color: #d6ebf0; padding: 2em; border-radius: 0.25rem;" class="mb-60">
            <h2 class="mb-60">Create a New Factor | What's happening here:</h2>
            <p style="line-height: 1.5;" class="mb-60">
                The API is <strong>creating a new TOTP <a href="https://www.twilio.com/docs/verify/api/factor#create-a-new-factor-resource">Factor</a></strong> when you click "Set up two-factor authentication".
                This is how the Verify API connects a user (<code>identity</code>), the <a href="https://www.twilio.com/docs/glossary/totp">TOTP channel</a>, and your app.
                Each Factor returns the secret seed and URI used to create a QR code.
            </p>
            <p>
                For this demo, we're asking for a username that will be displayed in the account name for the authenticator app.
            </p>
        </div>
        <a href="/">Reset</a>
    </main>
</body>
</html>

The HTML renders another, simplistic, form with the QR code to scan, and a field for the TOTP code generated by the user's authenticator app. The field uses the pattern attribute to ensure that the only valid input is a 6-digit code. It also sets the inputmode attribute to "numeric" to have mobile browsers display a keyboard appropriate for entering digits, making it easier for the user to only enter numeric input.

Next, back in app.py, add the following function at the end of the file.

@app.post("/challenge")
async def process_verify_user_form(request: Request, code: str = Form(...)):
    factor = (
        twilio_client.verify.v2
        .services(TWILIO_VERIFY_SERVICE_SID)
        .entities(request.session.get("seed", ""))
        .factors(request.session.get("sid", ""))
        .update(auth_payload=code)
    )
    if factor.status == "verified":
        request.session["flash_message"] = "Factor setup complete!"
        return RedirectResponse("/token", status_code=302)
    return RedirectResponse("/challenge", status_code=302)

The process_verify_user_form() function retrieves the code that the user submitted from the form and attempts to verify the new Factor using it, along with the Factor's seed and SID, retrieved from the current session.

If the Factor is successfully verified, a confirmation message is written to the session as a flash message and the user is redirected to the /token route, where the message will be displayed. Otherwise, they'll be redirected back to /challenge, to scan the QR code and try again.

Flash messages are stored directly in the session under the "flash_message" key. The handler that displays the next page pops this value from the session before rendering, so it only appears once.

Step 7: Add the ability to validate TOTP codes after Factor verification

Now, you'll add the third and final feature: the ability to validate TOTP codes, post-Factor verification. Add the following two functions to app.py.

@app.get("/token")
async def show_qr_code_form(request: Request):
    message = request.session.pop("flash_message", None)
    return templates.TemplateResponse(
        request,
        "enter-code.html",
        {
            "friendlyName": request.session.get("friendly_name", ""),
            "identity": request.session.get("seed", ""),
            "message": message,
        },
    )
@app.post("/token")
async def process_qr_code_form(request: Request, code: str = Form(...)):
    challenge = (
        twilio_client.verify.v2
        .services(TWILIO_VERIFY_SERVICE_SID)
        .entities(request.session.get("seed", ""))
        .challenges.create(
            factor_sid=request.session.get("sid", ""),
            auth_payload=code,
        )
    )
    request.session["flash_message"] = (
        "Verification success." if challenge.status == "approved" else "Verification failed."
    )
    return RedirectResponse("/token", status_code=302)

The first, show_qr_code_form(), renders an HTML form where the user can enter and submit a TOTP code generated by their authenticator app. It pops any flash message out of the session before rendering, so the message is consumed and won't reappear on the next page load. The second, process_qr_code_form(), verifies the code with Twilio Verify.

If Twilio Verify marks the code as "approved", then a flash message confirming that is written to the session. Otherwise, a message confirming that the code was invalid is written. The user is then redirected back to the /token form, where the message will be displayed.

Now, in templates create a new file named enter-code.html, and paste the code below into the file.

<!DOCTYPE html>
<html>
<head>
    <title>Enter TOTP Code</title>
    <link href="https://assets.twilio.com/public_assets/paste-fonts/1.5.2/fonts.css" rel="stylesheet">
    <link href="/css/styles.css" rel="stylesheet">
</head>
<body class="container">
    <main>
        <h1>Enter TOTP Code</h1>
        <p class="mt-60">
        This demo of the <a href="https://www.twilio.com/docs/verify/api">Twilio Verify API</a> shows how to set up two-factor authentication (2FA) with a third-party authenticator app like <a href="https://authy.com/download/">Authy</a> or Google Authenticator using the <a href="https://www.twilio.com/docs/glossary/totp">time-based one-time password (TOTP)</a> standard.
        </p>
        <hr class="mt-60">
        {% if message %}
        {% set alert_type = 'success' if message in ['Verification success.', 'Factor setup complete!'] else 'error' %}
        <p class="mt-60 alert alert-{{ alert_type }}">{{ message }}</p>
        {% endif %}
        <form action="/token" method="post" class="mt-60 mb-100">
            <label for="code">Enter the generated by your authenticator app:</label>
            <div class="mt-20" style="display: flex; flex: auto; column-gap: 0.25rem; flex-shrink: 0;">
                <input
                    id="code"
                    inputmode="numeric"
                    name="code"
                    required
                    pattern="\d{6}"
                    placeholder="123456"
                    type="text"
                >
                <button type="submit" class="primary" style="flex-grow: 1;">Verify</button>
            </div>
        </form>
        <p>
            Demo is running for username '{{ friendlyName }}' with identity '{{ identity }}'.
            <a href="">Validate a code</a> or <a href="/">Reset</a>
        </p>
    </main>
</body>
</html>

Similar to the previous template, this one renders a form with a field where the user can input and submit the code which their authenticator app generates. It also provides a link to start over and create a new Factor.

Note that this template uses Jinja2's conditional syntax. The {% if message %} check handles the case where no flash message exists, and the ternary expression 'success' if ... else 'error' sets the alert style class. This is equivalent to Twig's is not null check and ternary operator, just written in Python's style.

Step 8: Add the application's CSS

The last step is to add the stylesheet. In the static/css directory, create a new file named styles.css and paste in the CSS from the project's GitHub repository.

FastAPI serves files from this directory at the /css/ path, as configured by the app.mount() call in Step 4. The templates' <link href="/css/styles.css"> tags will resolve correctly once the file is in place.

Test that the application works

Finally, it's time to test that the code works as expected. Start the application by running the following command.

uvicorn app:app --reload

Your server will start on port 8000, as shown by terminal output similar to the following.

INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

You can now navigate to http://localhost:8000 to test the TOTP Factor creation process.

Web page for setting up two-factor authentication with Twilio API using time-based one-time passwords.

Enter a username of your choice and click Set up two-factor authentication.

Twilio page showing setup of two-factor authentication using a QR code, input field, and create a new TOTP factor section.

You will be redirected to the Verify New TOTP Factor form. With your authentication app, scan the QR code, enter the 6-digit code that it generates into the form, and click Verify.

Screenshot of a TOTP code entry screen for two-factor authentication with a verification prompt.

You'll then be redirected to the Enter TOTP Code form, where you'll see whether the Factor was set up successfully or not, as in the screenshot above.

Web page showing TOTP code verification form with a success message and fields for code entry.

If the Factor was set up successfully, enter the next 6-digit code into the Enter TOTP Code form and click Verify. Again, you should see if it was successfully verified or not, as in the screenshot above.

That's the essentials of implementing passwordless authentication in Python using Twilio Verify

TOTP-based passwordless authentication using Python and Twilio Verify presents a compelling alternative to traditional password-based systems.

It offers a blend of enhanced security and user convenience, making it an attractive option for modern applications. While passwordless auth introduces some extra complexity to your application, the benefits — especially regarding security and user experience — are worth the investment.

Dylan Frankcom is a Software Engineer on Twilio's Developer Content team. He builds educational content to help developers get the most out of Twilio's APIs. You can reach him at dfrankcom [at] twilio.com or find him on LinkedIn.

Otp icon created by Magnific on Flaticon.