TextPilot

Ship a Two-Factor Auth Flow in 20 Minutes

A step-by-step guide to adding SMS-based two-factor authentication to your app with TextPilot — from zero to verified in 20 minutes.

Ship a Two-Factor Auth Flow in 20 Minutes

SMS-based two-factor authentication (2FA) is still one of the most practical ways to add a second layer of security to your app. Yes, TOTP apps and passkeys exist — but SMS 2FA has one massive advantage: every user already knows how to use it.

Here's how to build it with TextPilot in about 20 minutes.

What We're Building

A complete 2FA flow:

  1. User logs in with email/password
  2. Server generates a 6-digit code and sends it via SMS
  3. User enters the code
  4. Server verifies and creates a session

Prerequisites

npm install textpilot

You'll need a TextPilot account and an API key from the dashboard.

Step 1: Generate and Store the Code (2 minutes)

import { TextPilot } from 'textpilot'

const tp = new TextPilot(process.env.TEXTPILOT_API_KEY!)

function generateCode(): string {
  // Cryptographically random 6-digit code
  const array = new Uint32Array(1)
  crypto.getRandomValues(array)
  return String(array[0] % 1000000).padStart(6, '0')
}

// Store codes with expiry (use Redis, DB, or in-memory store)
const codeStore = new Map<string, { code: string; expires: number }>()

function storeCode(userId: string): string {
  const code = generateCode()
  codeStore.set(userId, {
    code,
    expires: Date.now() + 5 * 60 * 1000, // 5 minutes
  })
  return code
}

Step 2: Send the Code (3 minutes)

async function sendVerificationCode(userId: string, phone: string) {
  const code = storeCode(userId)

  await tp.send(phone, `Your verification code is ${code}. It expires in 5 minutes.`)

  return { sent: true }
}

That's it. Three lines to generate, store, and send. TextPilot handles queuing, delivery, and retry logic.

Step 3: Verify the Code (3 minutes)

function verifyCode(userId: string, inputCode: string): boolean {
  const stored = codeStore.get(userId)

  if (!stored) return false
  if (Date.now() > stored.expires) {
    codeStore.delete(userId)
    return false
  }

  // Constant-time comparison to prevent timing attacks
  const expected = stored.code
  if (expected.length !== inputCode.length) return false

  let mismatch = 0
  for (let i = 0; i < expected.length; i++) {
    mismatch |= expected.charCodeAt(i) ^ inputCode.charCodeAt(i)
  }

  // Delete code after verification attempt (one-time use)
  codeStore.delete(userId)

  return mismatch === 0
}

Step 4: Wire It Up (10 minutes)

Here's a complete example using Hono (works on Cloudflare Workers, Node.js, Bun, Deno):

import { Hono } from 'hono'
import { TextPilot } from 'textpilot'

const app = new Hono()
const tp = new TextPilot(process.env.TEXTPILOT_API_KEY!)
const codeStore = new Map<string, { code: string; expires: number }>()

// Step 1: User submits credentials, we send 2FA code
app.post('/auth/login', async (c) => {
  const { email, password } = await c.req.json()

  // Validate credentials (your existing auth logic)
  const user = await validateCredentials(email, password)
  if (!user) return c.json({ error: 'Invalid credentials' }, 401)

  // Generate and send code
  const code = generateCode()
  codeStore.set(user.id, {
    code,
    expires: Date.now() + 5 * 60 * 1000,
  })

  await tp.send(user.phone, `Your login code is ${code}`)

  return c.json({
    requiresVerification: true,
    // Return masked phone for UI: +1***...1234
    maskedPhone: maskPhone(user.phone),
  })
})

// Step 2: User submits the code
app.post('/auth/verify', async (c) => {
  const { email, code } = await c.req.json()
  const user = await getUserByEmail(email)

  if (!user || !verifyCode(user.id, code)) {
    return c.json({ error: 'Invalid or expired code' }, 401)
  }

  // Create session
  const session = await createSession(user.id)

  return c.json({ token: session.token })
})

// Step 3: Resend code (with rate limiting)
app.post('/auth/resend', async (c) => {
  const { email } = await c.req.json()
  const user = await getUserByEmail(email)

  if (!user) return c.json({ error: 'Not found' }, 404)

  const code = generateCode()
  codeStore.set(user.id, {
    code,
    expires: Date.now() + 5 * 60 * 1000,
  })

  await tp.send(user.phone, `Your new login code is ${code}`)

  return c.json({ sent: true })
})

function maskPhone(phone: string): string {
  return phone.slice(0, 3) + '***' + phone.slice(-4)
}

Step 5: Add Rate Limiting (2 minutes)

Don't let anyone spam SMS sends. A simple in-memory rate limiter:

const rateLimits = new Map<string, number[]>()
const MAX_CODES_PER_HOUR = 5

function checkRateLimit(userId: string): boolean {
  const now = Date.now()
  const hourAgo = now - 60 * 60 * 1000

  const attempts = (rateLimits.get(userId) ?? []).filter(t => t > hourAgo)

  if (attempts.length >= MAX_CODES_PER_HOUR) return false

  attempts.push(now)
  rateLimits.set(userId, attempts)
  return true
}

Security Considerations

  1. Rate limit aggressively: 5 codes per hour per user is plenty. This prevents SMS pumping attacks.
  2. One-time codes: Delete the code after any verification attempt, successful or not.
  3. Short expiry: 5 minutes is standard. Don't go longer than 10.
  4. Constant-time comparison: Prevents timing attacks on code verification.
  5. Don't leak user existence: Return the same error for "user not found" and "wrong code."
  6. Log verification attempts: Track failed attempts for abuse detection.

Cost

On TextPilot's Hobby plan, you get 50 free messages per month. That's 50 login verifications — plenty for development and small apps. On Builder ($19/mo), you get ~1,550 messages included.

A typical app with 500 active users doing 2FA once a week = ~2,000 messages/month. That's well within the Builder plan with room to spare.

Going Further

  • Backup codes: Generate 10 one-time backup codes at registration for users who lose phone access
  • Remember device: Set a cookie after successful 2FA so trusted devices skip the code step
  • Adaptive 2FA: Only require 2FA for new devices, suspicious locations, or sensitive actions
  • TOTP fallback: Let users choose between SMS and authenticator app

Conclusion

SMS 2FA isn't the most cutting-edge auth method, but it's the most accessible. Every user has a phone number. Nobody needs to install an authenticator app. And with TextPilot, adding it to your app is genuinely a 20-minute task.

Get your API key →