Skip to main content
Implement SMS-based two-factor authentication (2FA) using the Telnyx Messaging API. This guide covers generating, sending, and verifying one-time passwords (OTPs) with security best practices.
Consider the Verify API first. Telnyx offers a dedicated Verify API that handles OTP generation, delivery, and verification for you — including retry logic, rate limiting, and multi-channel support (SMS, voice, WhatsApp). Use this guide only if you need full control over the 2FA flow.

How SMS 2FA works


Generate and send an OTP

Generate a cryptographically secure OTP and send it via SMS:
import os
import secrets
import time

from telnyx import Telnyx

client = Telnyx(api_key=os.environ.get("TELNYX_API_KEY"))

# In production, use a database (Redis, PostgreSQL, etc.)
otp_store = {}

OTP_LENGTH = 6
OTP_EXPIRY_SECONDS = 300  # 5 minutes


def generate_otp() -> str:
    """Generate a cryptographically secure numeric OTP."""
    # Use secrets module for secure random generation
    return "".join(secrets.choice("0123456789") for _ in range(OTP_LENGTH))


def send_otp(phone_number: str) -> dict:
    """Generate and send an OTP to the given phone number."""
    otp = generate_otp()

    # Store OTP with expiry and attempt counter
    otp_store[phone_number] = {
        "otp": otp,
        "expires_at": time.time() + OTP_EXPIRY_SECONDS,
        "attempts": 0,
        "max_attempts": 3,
    }

    # Send via Telnyx
    response = client.messages.send(
        from_=os.environ.get("TELNYX_FROM_NUMBER"),
        to=phone_number,
        text=f"Your verification code is: {otp}. It expires in 5 minutes.",
    )

    return {"message_id": response.data.id, "expires_in": OTP_EXPIRY_SECONDS}


def verify_otp(phone_number: str, submitted_otp: str) -> bool:
    """Verify the submitted OTP. Returns True if valid."""
    record = otp_store.get(phone_number)
    if not record:
        return False

    # Check expiry
    if time.time() > record["expires_at"]:
        del otp_store[phone_number]
        return False

    # Check attempts
    record["attempts"] += 1
    if record["attempts"] > record["max_attempts"]:
        del otp_store[phone_number]
        return False

    # Constant-time comparison to prevent timing attacks
    if secrets.compare_digest(record["otp"], submitted_otp):
        del otp_store[phone_number]  # One-time use
        return True

    return False


# Example usage
result = send_otp("+15559876543")
print(f"OTP sent, message ID: {result['message_id']}")

# Later, when user submits the code:
# is_valid = verify_otp("+15559876543", "123456")

Security best practices

Never use Math.random(), rand(), or similar non-cryptographic functions for OTP generation. Use:
LanguageSecure Function
Pythonsecrets.choice() or secrets.token_hex()
Nodecrypto.randomBytes()
RubySecureRandom.random_number()
Gocrypto/rand.Int()
JavaSecureRandom.nextInt()
.NETRandomNumberGenerator.GetBytes()
PHPrandom_int()
OTPs should expire after a short window (3-5 minutes is typical). Never allow OTPs to be valid indefinitely.
  • Store the expiry timestamp alongside the OTP
  • Check expiry before validating
  • Delete expired OTPs proactively
Allow a maximum of 3 verification attempts per OTP. After exceeding the limit, invalidate the OTP and require the user to request a new one. This prevents brute-force attacks on short numeric codes.
Always use constant-time string comparison when verifying OTPs to prevent timing attacks:
LanguageFunction
Pythonsecrets.compare_digest()
Nodecrypto.timingSafeEqual()
Gosubtle.ConstantTimeCompare()
JavaMessageDigest.isEqual()
.NETCryptographicOperations.FixedTimeEquals()
PHPhash_equals()
Prevent abuse by limiting how frequently a user can request new OTPs:
  • Per phone number: Maximum 1 OTP request per 60 seconds
  • Per IP address: Maximum 10 OTP requests per hour
  • Per account: Maximum 5 OTP requests per hour
Return the same “OTP sent” response regardless of whether the number exists in your system to prevent enumeration attacks.
Use numeric-only OTPs (e.g., 847291) rather than alphanumeric codes. They are:
  • Easier for users to type on mobile
  • Compatible with SMS autofill on iOS and Android
  • Sufficient security when combined with attempt limits and expiry
A 6-digit code has 1,000,000 possible values — with a 3-attempt limit, the probability of guessing correctly is 0.0003%.
iOS and Android can automatically detect and fill OTP codes from SMS. To enable this:Android (SMS Retriever API): Include your app’s hash at the end of the message:
Your verification code is: 847291

FA+9qCX9VSu
iOS: iOS automatically detects codes from messages containing “code” or “passcode.” No special formatting needed, but keeping the OTP on its own line helps.

When to use the Verify API instead

The Telnyx Verify API is a better choice when:
RequirementDIY (this guide)Verify API
OTP generation & storageYou build itHandled for you
Retry logicYou build itBuilt-in
Rate limitingYou build itBuilt-in
Multi-channel (SMS + Voice + WhatsApp)Separate implementationSingle API
Delivery status trackingManual webhook handlingBuilt-in
Compliance & audit loggingYou build itBuilt-in
Use this guide’s DIY approach only when you need full control over the OTP flow, custom message templates, or integration with existing verification systems.