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:
- User logs in with email/password
- Server generates a 6-digit code and sends it via SMS
- User enters the code
- Server verifies and creates a session
Prerequisites
npm install textpilotYou'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
- Rate limit aggressively: 5 codes per hour per user is plenty. This prevents SMS pumping attacks.
- One-time codes: Delete the code after any verification attempt, successful or not.
- Short expiry: 5 minutes is standard. Don't go longer than 10.
- Constant-time comparison: Prevents timing attacks on code verification.
- Don't leak user existence: Return the same error for "user not found" and "wrong code."
- 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.
The Developer's Guide to 10DLC Compliance
Everything you need to know about 10DLC registration, why carriers require it, and how TextPilot handles compliance so you can focus on building.
AI Agents in Production: Lessons from Building TextPilot
What we learned building a developer SMS API with AI-assisted development — the tools, workflows, and hard-won lessons from shipping real infrastructure.