Adding 2FA and MFA to Your SaaS: TOTP, SMS and Recovery Done Safely
Founder & Lead Engineer, RAITHub
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.
| Factor | Strength | Main weakness | Role |
|---|---|---|---|
| Passkey / WebAuthn | Phishing-resistant, bound to the site | Device support and recovery UX | Strongest; offer where you can |
| TOTP authenticator app | Offline, no network to intercept | Lost phone needs recovery codes | Sensible default |
| SMS one-time code | Familiar, no app needed | SIM-swap, interception, delivery cost | Fallback only, never the only option |
| Email code | Universal | Only as strong as the email account | Weakest; 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?
| Option | Examples | Choose this when | Watch out for |
|---|---|---|---|
| Auth provider with MFA built in | A hosted authentication service | You use their login and want MFA, passkeys and recovery handled | Step-up on your own sensitive actions and org-level policy may still be yours |
| A TOTP library in your app | An RFC 6238 library for your language | You own your auth and want control over UX and storage | You own encryption, recovery codes, rate limits and replay defence |
| SMS-code service only | An SMS one-time-code API | A fallback for users without an authenticator app | SIM-swap and interception risk; cost; never your only factor |
| Hire a team to build it | RAITHub or another studio | Enterprise buyers require enforced MFA and you want it tested | Get 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.
Related posts
Ready to discuss your project?
Book a free 15-minute technical audit with our engineering team.