Back to BlogSecurity & Compliance

Adding 2FA and MFA to Your SaaS: TOTP, SMS and Recovery Done Safely

Rupak Amin

Founder & Lead Engineer, RAITHub

8 min read

RAITHub ships and tests production software. See QA as a Service or talk to us.

Add 2FA to a SaaS with a clear order: TOTP authenticator apps first, passkeys where you can, and SMS only as a fallback; verify a TOTP code against the current and one adjacent time step; store each secret encrypted at rest; issue one-time recovery codes at enrolment; and require a second factor again (step-up) before sensitive actions, not just at login.

This is engineering guidance for SaaS authentication, written to OWASP-level practice. It is not a certified penetration test, and shipping it does not produce a compliance attestation; where you need one, see the security testing service. If you would rather have it built and tested for you, see how RAITHub would build this below.

Which second factor should a SaaS offer first?

Offer an authenticator-app code (TOTP) as the default, and a passkey where the stack supports it; keep SMS as a fallback only. The reason is interception. The US NIST SP 800-63B digital identity guidelines treat SMS as a "restricted" authenticator, warning that out-of-band authentication using the public telephone network carries added risk, because codes can be redirected by SIM-swap or intercepted. TOTP, generated on the user's device, has no network to intercept.

FactorStrengthMain weaknessRole
Passkey / WebAuthnPhishing-resistant, bound to the siteDevice support and recovery UXStrongest; offer where you can
TOTP authenticator appOffline, no network to interceptLost phone needs recovery codesSensible default
SMS one-time codeFamiliar, no app neededSIM-swap, interception, delivery costFallback only, never the only option
Email codeUniversalOnly as strong as the email accountWeakest; avoid as a true second factor

How do you generate and verify a TOTP code correctly?

TOTP, defined in RFC 6238, derives a six-digit code from a shared secret and the current 30-second time step. At enrolment you create a random secret, show it as a QR code for the user's app, and confirm one code before you trust it. At verification you compare against the current step and one step either side, because the two clocks are never perfectly in sync.

import { authenticator } from 'otplib'

// Enrolment: generate a secret, render it as an otpauth:// URI for a QR code.
export function startEnrolment(userEmail: string) {
  const secret = authenticator.generateSecret() // store ENCRYPTED, not plain
  const uri = authenticator.keyuri(userEmail, 'Acme SaaS', secret)
  return { secret, uri }
}

// Verification: window of 1 allows the current step and one adjacent step.
authenticator.options = { window: 1 }
export function verify(token: string, secret: string): boolean {
  return authenticator.verify({ token, secret })
}

Keep the acceptance window small (one step either side), or you widen the guessing window for an attacker. Rate-limit verification attempts per account and lock after repeated failures, so six digits cannot be brute-forced. And store each used code's time step briefly to reject a replay within the same window.

How should the TOTP secret and recovery codes be stored?

The TOTP secret is a credential: anyone who reads it can generate valid codes forever, so encrypt it at rest with a key held outside the database, and never log it. Recovery codes are one-time passwords for when the phone is lost; generate a set at enrolment, show them once, and store only their hashes, marking each as used when it is spent.

CREATE TABLE user_mfa (
  user_id       uuid PRIMARY KEY REFERENCES users(id),
  totp_secret   bytea NOT NULL,          -- encrypted; the app decrypts with a KMS key
  enabled_at    timestamptz,
  last_step     bigint                   -- last accepted time step, to reject replays
);

CREATE TABLE mfa_recovery_codes (
  user_id   uuid NOT NULL REFERENCES users(id),
  code_hash text NOT NULL,               -- hash of a single-use code, never the code
  used_at   timestamptz
);

Recovery codes are the real test of a 2FA design, because "I lost my phone" is the common case, not the rare one. Without them, your support team ends up disabling 2FA over email, which is the exact bypass an attacker wants. Treat an account-recovery path as part of the authentication system, as secure as the login it restores. How 2FA fits the wider auth choice (NextAuth, Clerk, Better Auth, and what each gives you) is in Better Auth vs NextAuth vs Clerk.

When should you ask for the second factor again?

At login, and again before sensitive actions. This is "step-up" authentication: even in a valid session, re-verify before changing the password, adding a new 2FA device, viewing recovery codes, changing payout details or exporting data. A stolen session cookie should not be enough to take over the account or move money. Record each step-up in the audit log, as in the SaaS audit log design guide, so a later review shows who re-authenticated and when.

For B2B SaaS, let a workspace admin require 2FA for everyone in the organisation, and show which members have it enabled. Enterprise buyers ask for enforced MFA on the security questionnaire, so an organisation-level switch is often what closes the deal.

Do-it-yourself estimate: 1 week for TOTP enrolment, verification, encrypted storage and recovery codes if you own your auth, plus a few days for step-up and an organisation-level enforcement switch. The main risks are storing the secret in plain text, no rate limit on the code box, and a recovery path weaker than the login it protects.

Buy, build or hire?

OptionExamplesChoose this whenWatch out for
Auth provider with MFA built inA hosted authentication serviceYou use their login and want MFA, passkeys and recovery handledStep-up on your own sensitive actions and org-level policy may still be yours
A TOTP library in your appAn RFC 6238 library for your languageYou own your auth and want control over UX and storageYou own encryption, recovery codes, rate limits and replay defence
SMS-code service onlyAn SMS one-time-code APIA fallback for users without an authenticator appSIM-swap and interception risk; cost; never your only factor
Hire a team to build itRAITHub or another studioEnterprise buyers require enforced MFA and you want it testedGet the recovery, replay and rate-limit tests in the handover

How do you test 2FA?

  • Clock skew: accept a code from the adjacent time step and reject one two steps away.
  • Replay: submit the same valid code twice in one window and assert the second is rejected.
  • Brute force: hammer the code box and assert rate limiting and lockout trigger.
  • Recovery: use a recovery code, assert it works once and never again, and that regenerating codes invalidates the old set.
  • Step-up: with a valid session, attempt a sensitive action and assert re-verification is required and audited.

How RAITHub would build this

  • Factors: TOTP as default, passkeys where supported, SMS as a fallback only, with an organisation-level enforcement switch for B2B.
  • Storage and verification: encrypted secrets, a small verification window, rate limiting, replay rejection and hashed single-use recovery codes.
  • Step-up: re-verification before password, device, payout and export changes, each written to the audit log.
  • Tests: clock-skew, replay, brute-force, recovery and step-up tests in CI.

Timeline: 2FA inside a new SaaS build fits the 4–6 week fixed scope; added to an existing product it is a bounded piece in the 6–12 week backend range. You receive: authentication and recovery tests in CI, handover docs and runbooks, and full IP under NDA. RAITHub is not SOC 2 or ISO 27001 certified; it builds the controls and signs DPAs and SCCs.

Next step: a free 15-minute technical audit, then a written fixed quote. See the SaaS development service or the security testing service, compare the related API key management build, and book the audit.

Frequently asked questions

Is SMS or an authenticator app better for 2FA?

An authenticator app (TOTP). NIST SP 800-63B treats SMS as a restricted authenticator because codes on the public phone network can be redirected by SIM-swap or intercepted. A TOTP code is generated on the device with no network to intercept, so keep SMS as a fallback only.

Why verify a TOTP code against more than the current time step?

Because the user's device clock and your server clock are never perfectly in sync. Accepting the current step and one step either side (a window of one) handles normal drift. A larger window widens the guessing window, so keep it small and rate-limit attempts.

How should I store the TOTP secret?

Encrypted at rest with a key held outside the database, and never logged. The secret is a credential: anyone who reads it can generate valid codes indefinitely, so a plain-text secret in a leaked database defeats the whole feature.

What happens when a user loses their phone?

They use a one-time recovery code issued at enrolment, of which you store only hashes. Recovery is part of the authentication system and must be as secure as login; a support team disabling 2FA over email is the exact bypass attackers exploit.

When should I ask for 2FA again after login?

Before sensitive actions: changing a password, adding a 2FA device, viewing recovery codes, changing payout details or exporting data. This step-up means a stolen session cookie alone cannot take over the account, and each re-verification should be audit-logged.

2FAMFATOTPauthenticationSaaSsecurityTypeScript

Ready to discuss your project?

Book a free 15-minute technical audit with our engineering team.