Code Agency
18 min readBy Fabio Tielen

SMS for 2FA: what it's still good for, and what it isn't

SIM-swap risk is real; so is the customer who will never install an authenticator app. An honest matrix of SMS OTP against TOTP and passkeys — plus the attack that shows up on your invoice instead of your incident channel, the network APIs that turn a phone number back into a useful signal, and why the recovery path decides your real security level, not the login screen.

We run an SMS platform. Around 400,000 messages a month go through it, and a meaningful slice of those are six-digit codes. So when a client says "we'll just send a code by SMS" in a kickoff meeting, we are, commercially speaking, being handed money.

We usually push back anyway — and then, about a third of the time, we end up agreeing with them for a reason that has nothing to do with security. That combination is the whole post. SMS 2FA is neither the disaster the security-Twitter version says it is nor the reasonable default it was in 2016. It's a tool with a very specific shape, and most teams reach for it because it's the one they know rather than because it fits.

The useful question is never "is SMS secure". It's "secure against whom, and compared to what you'd actually ship instead".

What a code by SMS actually proves

An SMS OTP proves that whoever is logging in can read messages sent to a phone number.

Read that again, because the sloppy version — "it proves they have their phone" — is what makes people misjudge the risk. A phone number is not a device. It is a leased identifier, allocated by a mobile operator, and the operator can reassign it to a different SIM. That reassignment happens for entirely legitimate reasons every day: lost handsets, upgrades, number portability between carriers. The control point is a support desk staffed by people whose job is to be helpful to someone who says they've lost their phone.

That's the structural weakness. Every other criticism of SMS 2FA is downstream of it, or of the fact that the message travels in the clear through a network you don't own.

It's worth saying that the standards bodies got here first and were fairly measured about it. NIST's Digital Identity Guidelines classify one-time passcodes delivered over the public telephone network as a restricted authenticator in SP 800-63B-4 — not forbidden, but permitted only with a documented risk assessment, an alternative authenticator on offer, and notice to users that this method is being phased down. The same document tells verifiers they shall check the registered number is bound to a physical device, and should weigh risk indicators like a recent SIM change or number port before sending anything. It also caps SMS and voice codes at ten minutes of validity, where authenticator-app codes get no fixed ceiling.

"Restricted, with conditions" is a fair summary of reality. It is also a set of obligations most implementations quietly skip.

Three ways it fails, and only one of them is exotic

Real-time phishing is the volume attack, and it beats TOTP too. A user lands on a convincing clone, types their password, gets prompted for the code, receives a real code — because the phishing kit relayed the login to the real site — and types it in. The attacker forwards it within the validity window and takes the session cookie. Nothing about this needs a carrier, a SIM, or any telecom knowledge. Off-the-shelf toolkits do it, and the crucial point for anyone choosing between options is that an authenticator app does not help here. TOTP codes get relayed exactly as easily as SMS codes. If phishing is your threat model — and for consumer and SaaS accounts it overwhelmingly is — the meaningful line isn't SMS versus TOTP. It's origin-bound credentials versus everything else, and only WebAuthn is on the right side of it.

SIM swap is the targeted attack, and it's the one people mean when they say SMS is broken. An attacker with enough personal data convinces a carrier to move the number, or exploits a port-out to a different operator, then runs a password reset on everything. It's not a mass-market attack — it takes effort per victim — which is exactly why it concentrates on accounts worth the effort: crypto, banking, admin accounts, anyone with a public profile. If your product holds funds or grants privileged access, SIM swap is not a tail risk, it's the expected attack.

The network interior is the boring one. SS7 and roaming interconnect have known weaknesses, and a message sits in plaintext on a lock screen where a shoulder or a shared device can read it. Real, occasionally exploited, and far less common in practice than the first two. Don't lead with this one in a client meeting; you'll sound like you're reaching, and the first two arguments are already sufficient.

The failure that shows up on the invoice

Here's the one that isn't in most security write-ups, and it's the one we see most often because we operate the sending side.

SMS pumping — the industry calls it artificially inflated traffic, or AIT — turns your login form into a payout mechanism. A fraudster with access to a premium-rate number range points automation at your "send me a code" endpoint, generates hundreds of thousands of messages to ranges they earn a cut on, and you pay for every one. There's no data breach, no incident, nothing in your logs that looks like an attack. There's a bill. Reported cases run five to fifty times normal monthly spend inside a few hours, and the first signal is usually accounting, not engineering.

Any endpoint that sends a message on unauthenticated input is exposed: signup verification, password reset, "resend code", and increasingly the non-auth ones like app-download links and survey invitations. It costs nothing to attack and it is entirely automatable, which is a bad combination to have sitting on your marketing site.

The guard is not complicated, it just has to exist before the send:

lib/otp.ts — every check here runs before a single message is billed
const HOURLY_PER_NUMBER = 5
const DAILY_PER_NUMBER = 10
 
export async function requestOtp(rawNumber: string, ctx: RequestContext) {
  const number = normaliseE164(rawNumber)
  if (!number) return genericOk() // never leak which numbers are valid
 
  // 1. Countries you don't serve are pure attack surface.
  if (!ALLOWED_PREFIXES.some((p) => number.startsWith(p))) return genericOk()
 
  // 2. Per-number, per-IP and per-account budgets. All three, not one.
  const spent = await budgets.consume({
    number,
    ip: ctx.ip,
    account: ctx.accountId,
    hourly: HOURLY_PER_NUMBER,
    daily: DAILY_PER_NUMBER,
  })
  if (!spent.allowed) return genericOk()
 
  // 3. Is this number real, reachable, and the type you expect?
  //    An HLR lookup costs a fraction of an SMS and kills most fake traffic.
  const lookup = await sms.lookup(number)
  if (!lookup.reachable || lookup.type === "voip") return genericOk()
 
  // 4. A resend is a backoff, not a button. 30s, then 60s, then 120s.
  if (spent.sinceLast < spent.requiredBackoff) return genericOk()
 
  await sms.send({ to: number, template: "otp", code: await mintCode(number) })
  return genericOk()
}

Two details in there matter more than the rate limits. Every rejection returns the same response as a success, for the same reason a spam-filtered contact form returns 200 — an attacker who can tell "blocked" from "sent" will tune around your thresholds by lunchtime. And the country allowlist does more work than everything else combined: pumping needs premium ranges, which are almost never in the markets a Belgian SME actually sells to.

Then set a hard spend cap at the platform with an alert well below it, because every guard you write is a guard that can have a bug in it. We do this for clients on our own platform; ask whoever sends your messages whether they can cap you, and treat "no" as an answer.

The honest matrix

Nobody picks an auth method in the abstract. They pick it for an audience, against a threat, with a budget. So:

Phishing-resistantSurvives SIM swapUser frictionRecovery riskWhere we actually use it
Password onlyNon/aNoneHighNowhere, as a sole factor
SMS OTPNoNoVery low — no installHigh (it is the recovery path)Reach-first consumer flows, low value at risk
E-mail OTP / magic linkNoYesLowAs strong as the mailboxSame tier as SMS; also inherits your deliverability problems
TOTP (authenticator app)NoYesMedium — install, scan, and a lost phone is a support ticketDepends entirely on the backup codesStaff, admin panels, anything internal
Push approval with number matchingPartly — blocks fatigue, not relayingYesLow, once the app existsMediumProducts that already ship an app
Passkey, syncedYesYesLowest of all, after the first oneMoves to the ecosystem accountDefault for new consumer and SaaS logins
Passkey, device-bound / security keyYesYesHighest — hardware to buy and carryNeeds a registered spareAdmin, infrastructure, anyone who can wreck a production database
itsme / eID (BE)YesYesLow here, unusable abroadHandled by the identity providerBelgian consumer flows that need real identity

Read the phishing column first. It is the only one where SMS and TOTP land in the same box, and it is the column that decides most real incidents.

Then read the friction column, because that's the one that gets ignored in security reviews and then decides the project. An authenticator app is meaningfully harder than SMS for a non-technical audience: install something, understand what it's for, scan a code, and now own a secret whose loss is your problem. For a consumer product with a broad demographic, "TOTP instead of SMS" is not a free upgrade — it is a conversion cost paid to fix a phishing problem TOTP doesn't actually fix.

That's the argument that keeps SMS alive. Not that it's good. That the realistic alternative for that audience was nothing.

Where SMS genuinely earns its place

The reframe that makes all of this tractable: stop treating SMS as a factor and start treating it as a channel and a signal.

  • Out-of-band notification of security events. "Your password was changed", "a new device signed in", "your delivery address was updated." This is arguably SMS's best security use and it isn't authentication at all. It reaches people who don't read e-mail, it arrives in seconds, and it turns a silent account takeover into a phone call to your support line. Cheap, high-value, no downside.
  • Confirming an action inside an already-authenticated session. Approving a bank transfer, confirming an unusual order. The user is already signed in; SMS adds a second channel an attacker with only session access doesn't have. Note the ordering — it's a step-up, not the front door.
  • Audiences where the alternative is nothing. Field technicians on shared devices, an older customer base, anyone whose relationship with your product is three logins a year. We have written before about how badly "just install our app" performs as a step in any funnel; it performs no better when the app is an authenticator.
  • Verifying the number once, at signup, as an identifier. Confirming someone controls a number before you use it for delivery updates is completely reasonable. It becomes a problem only when that one-time check is silently promoted into a permanent login factor.
  • Operational messaging generally — appointment reminders, delivery windows, two-way confirmations. This is where the platform actually pays for itself, and no security claim is being made at all.

And the honest counterpart: if SMS can reset the password, SMS is your security level. It doesn't matter what the login screen offers. A passkey-protected account with an SMS recovery path is an SMS-protected account with extra steps, and every serious attacker knows to go straight to the reset flow. This is the single most common mistake we find when reviewing an existing setup — a beautiful modern login, and a recovery path from 2014 sitting behind it.

The thing that changes the SMS conversation

The interesting development isn't a new kind of code. It's that operators started exposing what they know.

Under GSMA Open Gateway, mobile networks now offer two APIs that matter here. Number Verification confirms that the device making the request is genuinely on the mobile connection tied to that number — silently, over the data connection, no code, nothing to type or relay. SIM Swap answers a much cheaper question: has this number been moved to a different SIM recently?

Both are commercially live across several European markets — Germany's three main operators launched them, Spain's did too, and the initiative now covers dozens of carrier groups and a large majority of global connections. Coverage is still per-operator and per-country, which is exactly why you treat these as a signal rather than a dependency:

lib/step-up.ts — the SIM-swap check turns a code into evidence
export async function assessOtpRisk(number: string) {
  const swap = await network.simSwap.check(number, { maxAgeHours: 168 })
 
  // No coverage for this operator? Fall through, don't fail closed on a
  // signal you can't get for half your users.
  if (swap.status === "unavailable") return { action: "send_otp" }
 
  // Recent swap on an account worth taking over: an SMS code proves nothing
  // useful right now. Escalate to a channel the swap didn't compromise.
  if (swap.swappedWithinWindow) {
    return { action: "escalate", reason: "recent_sim_swap" }
  }
 
  return { action: "send_otp", confidence: "sim_stable" }
}

That flips the economics. The expensive part of SIM swap for a defender was never detecting it afterwards — it was that the OTP flow had no opinion about it. A seven-day swap window check costs less than the message it gates, and it directly satisfies the "consider device swap, SIM change or number porting" language NIST already put in the spec. Where the operator supports it, Number Verification goes further and removes the code entirely, which also removes the thing a phishing kit relays.

This is the direction we're pointing clients who genuinely need to keep a phone number in the loop, and it's the work we do on top of the platform rather than something you get by switching SMS vendors.

What we build by default now

For a new custom application, the ladder is:

  1. Passkeys as the primary path, offered with conditional UI so the browser surfaces an existing credential in the username field rather than making the user find a button. Registration is prompted after the first successful sign-in, not during signup — asking someone to create a credential before they have an account is how you get a 4% adoption rate.
  2. TOTP as the portable fallback, with backup codes shown once and a clear warning that they are the actual key. This is what covers the user on a work laptop with a locked-down browser, or the one whose ecosystem you don't support.
  3. SMS as break-glass and as the notification channel, rate-limited as above, never as the default and never silently.
  4. A recovery path designed against the same threat model as the login, which usually means identity verification or a human in the loop for high-value accounts — not a code to whatever number is on file.
app/(auth)/sign-in.tsx — the ladder, in the order the user meets it
const options = await beginAuthentication({ rpId: env.RP_ID })
 
// Conditional mediation: no button, no modal — the browser offers the passkey
// inline if the user has one for this origin. Silent no-op if they don't.
const credential = await navigator.credentials.get({
  publicKey: options,
  mediation: "conditional",
  signal: abort.signal,
})
 
if (credential) return finishAuthentication(credential) // done, one tap
 
// No passkey: password first, then the strongest second factor enrolled.
// SMS is last on purpose, and enrolling it never disables the others.
const factors = await getEnrolledFactors(user) // ["totp", "sms"]
return promptForFactor(factors.at(0) ?? "sms")

The line that does the real work is factors.at(0). Ordering the ladder in code, once, means nobody has to remember the policy in the next feature — and it means adding SMS to an account can never quietly downgrade it, because SMS sorts last by construction.

Passkeys, honestly

We're recommending passkeys, so we should be straight about where they hurt.

Recovery is the whole problem. The FIDO Alliance counted around five billion passkeys in use by May 2026, with roughly three-quarters of consumers having enabled one somewhere — the technology is not the constraint any more. But published estimates put the share of passkey users who lose access to every enrolled device inside an eighteen-month window somewhere around 6–11%, through lost phones, factory resets and platform migrations. That is not a rounding error, and whatever you build to catch those people is your real security floor. Design it first, not after launch.

Portability is still not solved in practice. A passkey created in Apple's keychain, Google's password manager or a third-party manager lives in that ecosystem. The FIDO Alliance's Credential Exchange Protocol is meant to fix exactly this and has been in draft long enough that we don't plan client architectures around it. In the meantime, a user with an iPhone and a Windows desktop is a support conversation waiting to happen — cross-device sign-in via a QR code works, but "scan this with your phone" is not obvious to everyone.

Shared and delegated accounts fight the model. Passkeys bind a credential to a person's device. A warehouse terminal three people use, or a bookkeeper who logs into a client's portal, does not fit that shape. Solve it with real multi-user accounts and roles rather than by weakening the credential — and if you can't, that account is where TOTP stays.

The desktop experience still lags the phone. On mobile it's a fingerprint and you're in. On a shared or managed desktop it can be a dialog nobody recognises. Budget for the support load in the first months.

None of that changes the recommendation. It changes what you build alongside it.

The Belgian and EU footnote

Two things worth knowing if your users are here.

itsme is the pragmatic answer for a lot of Belgian consumer flows. It's phishing-resistant, tied to a real identity, and — the part that matters commercially — people already have it and already trust it, because their bank and the government use it. For anything with a KYC or contractual dimension it beats rolling your own second factor. Its ceiling is geography: it is useless the moment your audience is outside Belgium.

eIDAS 2.0 is the medium-term shift. Regulation (EU) 2024/1183 came into force in May 2024 and obliges every member state to make an EU Digital Identity Wallet available to citizens by the end of 2026, with organisations in regulated sectors — banking, telecom, transport, energy — required to accept it a year later. If you're building anything with an identity dimension on a multi-year horizon, design the identity layer so a wallet can plug into it rather than assuming your own factors are the end state.

One compliance detail people get wrong: under PSD2, the EBA does treat the SIM as a valid possession element, so SMS OTP combined with a knowledge factor can satisfy strong customer authentication. What it does not do on its own is dynamic linking — binding the code to the specific amount and payee — because the message content isn't protected. "SMS is PSD2-compliant" is true for one requirement and false for the other, and that distinction has cost people an audit finding.

The rules we apply

  • Passkeys first, ordered in code so the ladder can't be reordered by accident.
  • SMS is never the strongest factor on an account, and never the recovery path for one that holds money or admin rights.
  • Every send endpoint has a country allowlist, a per-number and per-IP budget, a resend backoff, and a spend cap with an alarm below it. Rejections and successes look identical from outside.
  • Use the SIM Swap API where the operator supports it; treat "unavailable" as no signal, not as a pass.
  • Send security notifications by SMS aggressively — it's the cheapest account-takeover detection you will ever deploy.
  • Design the recovery flow against the same threat model as the login, and write down what it is.
  • Never let a marketing or support tool with a "send SMS" button sit on the same credentials as the auth flow, for the same reason we split mail streams: one queue and one credential means one incident.

The short version

SMS 2FA proves control of a phone number, not possession of a phone, and a number is something a carrier can reassign at a support desk. That makes it weak against targeted SIM swap and useless against the real-time phishing that causes most actual takeovers — though so is the authenticator app most people propose as the fix. Only origin-bound credentials break that pattern, which is why passkeys are the default we build.

But SMS keeps a real job. It is the channel that reaches everyone with no install, the fastest way to tell a customer their account just changed, and a legitimate step-up inside a session that's already authenticated. Use it there, guard the send endpoint like the payout mechanism it can become, add a SIM-swap check where the operator offers one, and — above all — make sure it isn't quietly sitting at the bottom of your password reset flow deciding your security level for you.

That's the setup we build and run for clients: passkey-first authentication in the applications we develop, our own SMS platform behind the notifications and step-ups, on the infrastructure we operate ourselves. The answer to "should we use SMS for 2FA" is almost always yes — just not for the part you were asking about.

Want us to publish something specific?

Tell us what you'd like to read and we'll add it to our writing queue.

Get the next one in your inbox

New articles, videos and the occasional engineering note — a short mail when there’s something worth reading, nothing else.