Adding Phone-Based 2FA with Twilio Verify Without Storing Codes Yourself
Credential-only login falls to password reuse and breaches. Here's how to add phone-based 2FA with Twilio Verify in a Node.js app — without storing one-time codes in your own database.
07 Mar 2026, 09:52 UTC

Your users reuse passwords. That's not a moral failing — it's arithmetic. When any one of the dozen sites they use gets breached, credential-stuffing tools will try those pairs against your login within days. A second factor breaks that chain: even with the right password, an attacker still needs the user's phone. The useful takeaway here is that you don't have to build the code-generation, delivery, and expiry machinery yourself. Twilio Verify handles the one-time code lifecycle, and your backend only ever sees "challenge created" and "check approved."
The decision: keep verification state out of your database
The tempting DIY path is a table of codes, expiry timestamps, and attempt counters. It works until it doesn't: you now own code entropy, delivery retries, rate limiting, and the cleanup job. Worse, a leaked database now includes valid second-factor codes alongside the password hashes.
Verify flips this. You create a verification (a challenge tied to a phone number and channel), Twilio generates and delivers the code, and later you submit the user's input to a verification check that returns approved or not. Your database stores nothing about the code itself — at most a flag that the session passed the second factor. That shrinks both your attack surface and your compliance story.
Where the challenge fits in your login flow
The common mistake is sending a code before the password is confirmed, which lets anyone spam verification messages to arbitrary numbers (and inflate your bill). The safer ordering:
- User submits email and password. Verify credentials as usual, but do not create the session yet.
- Look up the user's confirmed phone number and create a Verify challenge for it.
- Hold a short-lived "pending 2FA" state (a signed cookie or a server-side pending-login record with a tight expiry).
- User enters the code; your backend submits it to the verification check.
- Only on approval do you issue the real session. On failure, allow a bounded number of retries.
This ordering matters twice: it prevents pre-auth SMS abuse, and it means a stolen pending-login token alone grants nothing.
A worked Node.js flow
Below is the shape of an Express-style implementation using the official Twilio helper library. Treat the exact method names as illustrative — confirm them against the SDK version in your package.json, since helper library APIs shift between major versions. Credentials come from environment variables, never source control.
// Run on your backend server. Requires the twilio npm package and
// env vars: TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_VERIFY_SID
const twilio = require("twilio");
const client = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
const verifySid = process.env.TWILIO_VERIFY_SID;
// Step 1: after password check succeeds, create the challenge
async function startChallenge(phoneE164) {
return client.verify.v2
.services(verifySid)
.verifications.create({ to: phoneE164, channel: "sms" });
}
// Step 2: check the code the user typed
async function checkCode(phoneE164, code) {
const check = await client.verify.v2
.services(verifySid)
.verificationChecks.create({ to: phoneE164, code });
return check.status === "approved";
}In your login route, call startChallenge after the password verifies, stash a pending-login marker, and on the code-submission route call checkCode. Create the session only when it returns true. The phone number should be in E.164 format (e.g., +14155550123); normalize and validate it at enrollment time, not at login time.
Two operational details worth doing from day one: rate-limit the resend endpoint per account and per IP, and cap attempts per challenge. Verification endpoints are a known cost-amplification and harassment vector — without limits, someone can run up your bill or flood a stranger's phone.
Trade-offs you should document, not hide
SMS possession is a real second factor, but it is not the strongest one. SIM-swap attacks — where an attacker convinces a carrier to move the victim's number to a new SIM — defeat it, and delivery reliability varies by region and carrier. Verify supports other channels (voice, email, authenticator-style factors) depending on configuration, so you can offer stronger options later without re-architecting. Also plan the recovery path now: users lose phones and change numbers, and "call support to reset 2FA" needs its own verification rigor or it becomes the weakest link.
Finally, pricing is per verification, so resend abuse and login-volume spikes both show up on the invoice. Watch the Twilio console usage against your expected traffic before launch.
Verify it before you ship it
In a trial account, run the full loop against a phone you control: submit a wrong code and confirm no session is created, then the correct code and confirm the session appears. Trigger the resend endpoint repeatedly and confirm your rate limiter engages. Check the console to confirm per-attempt billing matches your expectations. If all three pass, you have a second factor that meaningfully raises the cost of account takeover — without ever storing a code you could lose.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.