Skip to main content
RapidDev - Software Development Agency
App Featuresauth-security21 min read

How to Add Two-Factor Authentication to Your App (Copy-Paste Prompts Included)

Two-factor authentication adds a second verification step after password login — a 6-digit code from an authenticator app (Google Authenticator, Authy) or SMS. Supabase Auth includes TOTP 2FA for free on all plans. With FlutterFlow you can ship enrollment, QR code setup, and 6-digit verification in 2–4 hours for $0/month. The critical requirements are: hash backup codes before storage, allow ±1 TOTP time window for clock drift, and enforce 2FA verification even after password reset.

4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members

Feature spec

Intermediate

Category

auth-security

Build with AI

2–4 hours with FlutterFlow (TOTP) or 4–6 hours with SMS added

Custom build

1–2 weeks custom dev

Running cost

$0/mo TOTP-only; $0.10 per SMS verification (approx) if SMS enabled

Works on

Mobile

Everything it takes to ship Two-Factor Authentication — parts, prompts, and real costs.

TL;DR

Two-factor authentication adds a second verification step after password login — a 6-digit code from an authenticator app (Google Authenticator, Authy) or SMS. Supabase Auth includes TOTP 2FA for free on all plans. With FlutterFlow you can ship enrollment, QR code setup, and 6-digit verification in 2–4 hours for $0/month. The critical requirements are: hash backup codes before storage, allow ±1 TOTP time window for clock drift, and enforce 2FA verification even after password reset.

What Two-Factor Authentication Actually Is

Two-factor authentication (2FA) requires users to prove their identity with two independent things: something they know (their password) and something they have (their phone with an authenticator app or a number that receives SMS). The most widely used approach is TOTP (Time-based One-Time Password) — the authenticator app on the user's phone generates a 6-digit code that changes every 30 seconds using the RFC 6238 algorithm. Supabase Auth has built-in TOTP support via enrollMfa(), challengeMfa(), and verifyMfa() APIs, so you do not need a separate 2FA service for authenticator app support. SMS adds cost ($0.10 per verification via Twilio) but helps users who don't have or use authenticator apps. The product decisions are whether 2FA is optional or mandatory, how to handle a user who loses their phone, and whether a 'remember this device' option reduces friction for returning users.

What users consider table stakes in 2026

  • TOTP authenticator app support compatible with Google Authenticator, Authy, and Apple Passwords — all use the same RFC 6238 standard
  • QR code shown at enrollment that users can scan directly with their authenticator app, plus a manual entry fallback for the base32 secret
  • Backup codes (10 single-use codes) generated and shown once at enrollment — with a clear warning to save them before closing
  • Large, numeric-only input for the 6-digit code with autofocus and auto-submit when the 6th digit is entered — no 'Submit' button needed
  • 'Remember this device for 30 days' toggle that stores a device fingerprint so trusted devices skip the 2FA step on return
  • Clear 'I lost my phone' path that accepts a backup code and walks the user through re-enrolling a new device

Anatomy of the Feature

Six components build the complete 2FA flow. Supabase Auth handles the core TOTP logic — the critical implementation work is backup codes, trusted devices, and the enrollment UI.

Layers:UIBackendService

TOTP library

Backend

Supabase Auth has built-in TOTP MFA for all plans: supabase.auth.mfa.enroll({ factorType: 'totp' }) returns a secret and a QR code URI; supabase.auth.mfa.challenge({ factorId }) returns a challengeId; supabase.auth.mfa.verify({ factorId, challengeId, code }) verifies the 6-digit code and upgrades the session to AAL2. For custom Flutter implementations, dart_otp generates and verifies TOTP codes locally using RFC 6238.

Note: Allow ±1 time window in TOTP validation (window: 1 in dart_otp, or the default in Supabase Auth) to tolerate up to 30 seconds of clock drift. Without this, users with slightly inaccurate device clocks fail every verification attempt with no informative error.

QR code generator

UI

qr_flutter (Flutter package) renders the otpauth:// URI returned by Supabase Auth's enroll() call as a scannable QR code. URI format: otpauth://totp/{issuer}:{email}?secret={base32_secret}&issuer={issuer}. Below the QR code, display the base32 secret in a monospace font as a manual entry fallback for users whose authenticator app does not support QR scanning.

Note: Test the QR code with both Google Authenticator and Authy before shipping — they accept different issuer formats and a subtle encoding error can make one work but not the other. Do not URL-encode the colon between issuer and email; it should appear literally as {issuer}:{email} in the label part.

2FA enrollment screen

UI

A multi-step screen shown after the user enables 2FA in security settings: Step 1 shows the QR code and asks the user to scan it; Step 2 shows a 6-digit input field with autofocus and asks them to enter the code from their app; a successful verify() call saves the enrollment. The enrollment is not complete until the code is verified — pressing 'Skip' at this step must cancel the enrollment and leave 2FA disabled.

Note: Call supabase.auth.mfa.enroll() when the user arrives at Step 1, not when they click 'Enable 2FA'. The enroll call generates the secret — generating it too early and then not verifying leaves a dangling unverified factor in Supabase.

2FA verification screen

UI

Shown immediately after successful password login when the user has an enrolled 2FA factor. A TextFormField with keyboardType: TextInputType.number, maxLength: 6, autofocus: true that auto-submits (calls verify()) when the 6th digit is entered. Remaining time indicator showing seconds until the current code expires helps users who see 'Incorrect code' due to submitting a code in its last second.

Note: Rate limit: lock the screen after 5 failed attempts and show a wait timer or a backup code prompt. An unlimited retry loop makes the 6-digit TOTP vulnerable to brute force.

Backup codes

Backend

Generate 10 single-use 8-digit codes at enrollment time. Store bcrypt-hashed versions in a backup_codes table (user_id, code_hash, used_at). Show the plaintext codes once — with a prominent 'Save these now, they cannot be shown again' warning — and never again. On login attempt with a backup code, compare the hash, mark used_at = now() in the same transaction as authentication success, and log the event.

Note: Backup codes must be hashed with bcrypt before storage. Storing plaintext backup codes is equivalent to storing plaintext passwords — anyone with read access to the table can use them to bypass 2FA on any account.

SMS fallback (optional)

Service

Twilio Verify API: POST to /v2/Services/{serviceSid}/Verifications with channel='sms' sends a 6-digit code to the user's phone. POST to /v2/Services/{serviceSid}/VerificationChecks verifies the entered code. Call these from a Supabase Edge Function (not the Flutter app directly) using the Twilio Account SID and Auth Token stored in Supabase Secrets.

Note: SMS 2FA costs approximately $0.05 per SMS sent plus $0.05 per verification check (approx). At scale with 30% SMS adoption among 10,000 users, this becomes the dominant running cost. Consider making SMS a paid add-on or enforcing TOTP as the default with SMS as a paid upgrade.

The data model

Three tables complement Supabase Auth's internal MFA storage. Run in the Supabase SQL editor:

schema.sql
1-- Supabase Auth stores enrolled TOTP factors internally in auth.mfa_factors
2-- You do not need to create a table for TOTP secrets Supabase manages them
3
4-- Backup codes (hashed) for account recovery
5create table public.backup_codes (
6 id uuid primary key default gen_random_uuid(),
7 user_id uuid references auth.users(id) on delete cascade not null,
8 code_hash text not null, -- bcrypt hash of the 8-digit code
9 used_at timestamptz, -- null = unused; set in same transaction as auth success
10 created_at timestamptz not null default now()
11);
12
13alter table public.backup_codes enable row level security;
14
15-- Users can SELECT to check how many codes remain (not the codes themselves)
16create policy "Users can view own backup code count"
17 on public.backup_codes for select
18 using (auth.uid() = user_id);
19
20-- INSERT and UPDATE go through Edge Functions with service role key only
21
22-- Trusted devices (remember this device for 30 days)
23create table public.trusted_devices (
24 id uuid primary key default gen_random_uuid(),
25 user_id uuid references auth.users(id) on delete cascade not null,
26 device_hash text not null, -- SHA-256 of user_agent + installation_id or random token
27 device_name text,
28 expires_at timestamptz not null, -- 30 days from creation
29 created_at timestamptz not null default now()
30);
31
32alter table public.trusted_devices enable row level security;
33
34create policy "Users can view own trusted devices"
35 on public.trusted_devices for select
36 using (auth.uid() = user_id);
37
38create policy "Users can delete own trusted devices"
39 on public.trusted_devices for delete
40 using (auth.uid() = user_id);
41
42-- Clean up expired trusted devices
43create index trusted_devices_expiry_idx
44 on public.trusted_devices (expires_at);
45
46-- 2FA event audit log
47create table public.mfa_events (
48 id uuid primary key default gen_random_uuid(),
49 user_id uuid references auth.users(id) on delete cascade not null,
50 event_type text not null, -- 'enrolled', 'verified', 'failed', 'backup_used', 'disabled'
51 factor_type text not null default 'totp',
52 ip_address text,
53 created_at timestamptz not null default now()
54);
55
56alter table public.mfa_events enable row level security;
57
58-- Users can read their own audit events
59create policy "Users can view own MFA events"
60 on public.mfa_events for select
61 using (auth.uid() = user_id);
62
63-- Inserts only via Edge Function with service role key
64
65create index mfa_events_user_idx on public.mfa_events (user_id, created_at desc);

Heads up: Supabase Auth internally manages the TOTP secret and the enrolledFactors list in auth.mfa_factors — do not replicate this. Your tables handle the additional concerns: backup codes, trusted devices, and audit logging.

Build it — pick your path

Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.

Hand-built by developersFit for this feature:

Full Flutter implementation with dart_otp for TOTP, qr_flutter for QR code, Supabase Auth MFA APIs, bcrypt-hashed backup codes, trusted device fingerprinting, and clock drift tolerance. Required for production mobile 2FA.

Step by step

  1. 1Add dart_otp (TOTP generation/verification), qr_flutter (QR rendering), flutter_bcrypt (backup code hashing), and shared_preferences or flutter_secure_storage (trusted device tokens) to pubspec.yaml
  2. 2Implement enrollment: call supabase.auth.mfa.enroll() to get the TOTP secret and QR URI; render with qr_flutter; verify the 6-digit test code before saving enrollment
  3. 3Generate 10 backup codes client-side as crypto-random 8-digit integers; send to a Supabase Edge Function that bcrypt-hashes them and inserts to backup_codes table; show plaintext once and never again
  4. 4Implement post-login MFA gate: after successful signInWithPassword(), check supabase.auth.mfa.listFactors() for enrolled factors; if present, navigate to MFA verification screen before showing the main app
  5. 5Implement trusted device: on successful MFA, if user checked 'Remember this device', generate a random token, store it in flutter_secure_storage, insert device_hash (SHA-256 of token) and expires_at (30 days) to trusted_devices table; on next login, check device hash before requiring MFA

Where this path bites

  • Clock drift handling (allow ±1 TOTP window) must be explicitly configured in dart_otp — the default window is 0, causing failures for users with clocks 15–30 seconds off
  • Trusted device tokens stored in flutter_secure_storage are tied to the app installation — reinstalling the app or clearing app data removes them, requiring re-verification

Third-party services you'll need

TOTP 2FA via Supabase Auth is free. SMS adds Twilio cost. Open-source libraries handle the rest:

ServiceWhat it doesFree tierPaid from
Supabase Auth MFA (TOTP)enrollMfa(), challengeMfa(), verifyMfa() — TOTP factor management built into Supabase Auth on all plansIncluded in all Supabase plans at no additional costNo additional cost; standard Supabase plan pricing applies
Twilio Verify (SMS fallback)Sends and verifies SMS OTP codes via Verify API — more reliable than raw SMS for OTP$15 free trial credit$0.05 per SMS sent + $0.05 per verification check (approx)
dart_otp + qr_flutterTOTP code generation/verification and QR code rendering for the enrollment screenOpen-source Flutter packages, freeFree
Google Authenticator / AuthyFree authenticator apps users install to receive TOTP codes — no developer costFree for usersNo developer cost

Swipe the table sideways to see pricing.

What it costs to run

Drag through the tiers to see how your monthly bill scales with users — no surprises later.

Estimated monthly running cost

$0/mo

TOTP via Supabase Auth is free at any scale. SMS is avoidable at this size — recommend TOTP-only to 100% of early users. Supabase free tier handles backup_codes and trusted_devices tables.

Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.

What breaks when AI tools build this

The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.

Clock drift causes every TOTP code to be rejected with no explanation

Symptom: TOTP codes are valid only for a 30-second window based on the device's clock. If the user's phone clock is off by more than 15 seconds (more common than expected, especially on budget Android devices), every code they enter is rejected. The default dart_otp configuration uses a window of 0 — zero tolerance for clock skew. The user has no way to know the problem is their clock rather than a bug.

Fix: Set window: 1 in dart_otp's OTP.totp() call, which allows ±1 window (±30 seconds). In the error message for repeated failures, add: 'Make sure your device clock is set to automatic (Settings → Date & Time → Set Automatically)'. Supabase Auth's built-in TOTP already includes a ±1 window — this only applies to custom dart_otp implementations.

Backup codes stored in plaintext in the database

Symptom: AI tools generate 10 random codes at enrollment and insert them directly into the backup_codes table as plaintext strings. Anyone with read access to the table — an employee, a compromised database credential, or a Supabase RLS misconfiguration — can read all backup codes and use them to bypass 2FA on any enrolled account. Backup codes are credentials, not data.

Fix: Generate backup codes client-side, send them to a Supabase Edge Function with service role key, and hash each code with bcrypt (cost factor 10) before inserting to backup_codes table. Store only the hash. At login, compare the entered backup code against each stored hash with bcrypt.compare(). Mark used_at = now() and the session creation in the same database transaction so a failed session creation doesn't consume the code.

2FA bypassed via password reset flow

Symptom: A common implementation mistake: 2FA is required after password login, but not enforced when the user resets their password via email and logs in through the reset link. An attacker who has access to the user's email (a separate compromise) can reset the password, click the reset link, and log in with full access — never hitting the 2FA screen. The 2FA enrollment exists but is completely bypassed.

Fix: After a successful password reset login, check for enrolled 2FA factors and require verification before granting full app access. Treat the password-reset-login session the same as a fresh password login — it is not more trusted just because email was involved. Additionally, send a security notification email: 'Your password was reset and someone logged in — if this wasn't you, contact support.'

QR code not scannable due to wrong otpauth URI format

Symptom: Google Authenticator silently fails to add an account when the otpauth URI has encoding errors. Common mistakes: URL-encoding the colon separator in {issuer}:{email}, omitting the issuer query parameter, using regular base64 encoding instead of base32 for the secret, or including spaces in the issuer name. The authenticator app shows no error — the scan simply fails to create an account.

Fix: Use the exact URI format from the Supabase Auth enroll() response — it is correctly formatted. For custom dart_otp implementations: otpauth://totp/YourApp:user@email.com?secret=BASE32SECRET&issuer=YourApp. The label (YourApp:user@email.com) and issuer must match. Test with both Google Authenticator and Authy on a physical device before shipping.

Trusted device logic tied to browser localStorage instead of Flutter secure storage

Symptom: If 2FA is built as a hybrid app using a WebView, device trust stored in web localStorage gets cleared when the app is reinstalled, when the user clears app data, or when the WebView cache is purged. Users who checked 'Remember this device' find themselves challenged for 2FA again after every app update or phone wipe.

Fix: Use flutter_secure_storage for device trust tokens on mobile — it persists through app updates (though not app reinstalls) and is hardware-backed on iOS (Keychain) and Android (KeyStore). Store a random token in secure storage and its SHA-256 hash in the trusted_devices table. On each login, compare the stored token hash against the DB; on a match where expires_at > now(), skip 2FA.

Best practices

1

Always allow ±1 TOTP time window in verification to tolerate clock drift — a window of 0 causes random failures that users cannot diagnose

2

Hash backup codes with bcrypt before storage — treat them as passwords, not data, because they are credentials that bypass your second factor

3

Enforce 2FA verification after password reset, not just after initial password login — password reset is a common attack vector against 2FA-protected accounts

4

Generate backup codes and show them exactly once at enrollment, with a prominent and impossible-to-ignore warning that they cannot be retrieved again

5

Use flutter_secure_storage for trusted device tokens on mobile — localStorage and SharedPreferences are cleared on app reinstall and app data wipe

6

Rate limit 2FA verification attempts: lock after 5 consecutive failures and require a backup code or support contact — unlimited retries make 6-digit TOTP brute-forceable

7

Validate the otpauth URI format and test QR code scanning with both Google Authenticator and Authy on a real device before shipping — silent scan failures are the most common enrollment bug

8

Log every 2FA event (enrolled, verified, failed, backup_used, disabled) to a mfa_events table — the audit trail is essential for support when users get locked out

When You Need Custom Development

TOTP 2FA with FlutterFlow and Supabase Auth is achievable for standard mobile apps. Custom development becomes necessary when your security requirements expand beyond standard 2FA:

  • You need hardware security key (FIDO2/WebAuthn) as a 2FA option alongside TOTP — higher security for enterprise users who require phishing-resistant authentication
  • You need admin-enforced 2FA with a grace period and reminder emails — for example, 'all users must enroll within 14 days or lose access' with weekly nudge emails
  • You need detailed 2FA audit logs for compliance (SOC 2, HIPAA) — capturing device fingerprint, IP address, geolocation, and success/failure reason for every MFA event
  • You need adaptive 2FA that only challenges users on suspicious signals (new device, unusual location, login after long inactivity) — requires risk scoring and a rules engine

RapidDev builds these features for production

Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.

Get a fixed-price quote

Frequently asked questions

What's the difference between 2FA and MFA?

Two-factor authentication (2FA) uses exactly two factors: something you know (password) and something you have (phone with authenticator app). Multi-factor authentication (MFA) is the broader category — it can involve two or more factors, including biometrics (something you are). 2FA is the most common form of MFA. In practice, Supabase Auth calls it MFA in its API (mfa.enroll, mfa.verify) but the typical implementation is a two-factor flow.

Which authenticator apps work with my app?

Any authenticator app that supports TOTP (RFC 6238) works with Supabase Auth's built-in 2FA. This includes Google Authenticator (iOS and Android), Authy (iOS and Android, with cloud backup), Apple Passwords (iOS 17+, macOS Sonoma+), Microsoft Authenticator, and 1Password. They all read the same QR code format and generate the same 6-digit codes — your app has no knowledge of which app the user chose.

What if a user loses their phone with the authenticator?

This is why backup codes exist. At 2FA enrollment, generate 10 single-use 8-digit codes and show them once with a clear instruction to save them. When a user loses their phone, they enter a backup code at the 2FA screen instead of the 6-digit TOTP code. The backup code is marked as used and cannot be reused. If they have no backup codes, you need a manual identity verification process — typically email confirmation plus an ID check for high-security apps.

Is SMS 2FA safe enough?

SMS is significantly weaker than TOTP because it is vulnerable to SIM swapping (an attacker convinces a carrier to transfer the victim's number to a new SIM) and SS7 protocol interception. For most consumer apps, SMS is still much better than no 2FA. For financial, healthcare, or enterprise apps, TOTP or hardware keys are strongly preferred. Supabase Auth's TOTP is free and more secure — use SMS only as a fallback for users who cannot or will not use an authenticator app.

Can I make 2FA optional or mandatory for all users?

Both. Optional: show a '2FA' section in security settings; users can enroll or skip. Mandatory: after successful login, check if the user has an enrolled TOTP factor; if not, redirect to the enrollment screen before granting app access. For a middle ground, make 2FA mandatory for admin roles only by checking the user's role before requiring enrollment.

How do backup codes work?

Backup codes are one-time-use codes generated at 2FA enrollment. Each code is shown once (you must save them — they cannot be retrieved later). When you cannot access your authenticator app (lost phone, uninstalled app), you enter a backup code at the 2FA screen instead of the 6-digit TOTP. The code works once and is permanently marked as used. Most implementations give 10 codes; you can generate new ones (invalidating old ones) from your security settings once you regain access.

Does adding 2FA make registration harder?

2FA is not part of registration — it is an optional or required step after the first login. New users register normally with email and password, then are optionally or mandatorily redirected to 2FA enrollment. If enrollment is optional, most users will skip it unless you make the value clear. A pattern that works well: after first login, show a dismissible '2FA protects your account — set it up now (takes 2 minutes)' prompt with a 'Maybe later' option.

What's the 'remember this device' feature and how long should it last?

After a successful 2FA verification, a 'Remember this device for 30 days' toggle stores a device trust token in flutter_secure_storage (mobile) or a secure cookie (web). On subsequent logins from the same device within 30 days, the app checks the token and skips the 2FA step. 30 days is the standard duration — long enough that trusted devices feel seamless, short enough that a device left unattended does not permanently bypass 2FA. High-security apps (banking, healthcare) should use 7 days or disable device trust entirely.

RapidDev

Need this feature production-ready?

RapidDev builds two-factor authentication into real apps — auth, database, payments — at $13K–$25K.

Book a free consultation

30-min call. No commitment.

Want this built for you?

We ship production apps at a fixed price — $13K–$25K, 6–10 weeks, source code yours. You've seen what it takes; we do it every week.

Get a fixed-price quote

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.