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

- Tool: App Features
- Last updated: July 2026

## 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.

## 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.

- **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.
- **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.
- **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.
- **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.
- **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.
- **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.

## Data model

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

```sql
-- Supabase Auth stores enrolled TOTP factors internally in auth.mfa_factors
-- You do not need to create a table for TOTP secrets — Supabase manages them

-- Backup codes (hashed) for account recovery
create table public.backup_codes (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  code_hash text not null,           -- bcrypt hash of the 8-digit code
  used_at timestamptz,               -- null = unused; set in same transaction as auth success
  created_at timestamptz not null default now()
);

alter table public.backup_codes enable row level security;

-- Users can SELECT to check how many codes remain (not the codes themselves)
create policy "Users can view own backup code count"
  on public.backup_codes for select
  using (auth.uid() = user_id);

-- INSERT and UPDATE go through Edge Functions with service role key only

-- Trusted devices (remember this device for 30 days)
create table public.trusted_devices (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  device_hash text not null,         -- SHA-256 of user_agent + installation_id or random token
  device_name text,
  expires_at timestamptz not null,   -- 30 days from creation
  created_at timestamptz not null default now()
);

alter table public.trusted_devices enable row level security;

create policy "Users can view own trusted devices"
  on public.trusted_devices for select
  using (auth.uid() = user_id);

create policy "Users can delete own trusted devices"
  on public.trusted_devices for delete
  using (auth.uid() = user_id);

-- Clean up expired trusted devices
create index trusted_devices_expiry_idx
  on public.trusted_devices (expires_at);

-- 2FA event audit log
create table public.mfa_events (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  event_type text not null,          -- 'enrolled', 'verified', 'failed', 'backup_used', 'disabled'
  factor_type text not null default 'totp',
  ip_address text,
  created_at timestamptz not null default now()
);

alter table public.mfa_events enable row level security;

-- Users can read their own audit events
create policy "Users can view own MFA events"
  on public.mfa_events for select
  using (auth.uid() = user_id);

-- Inserts only via Edge Function with service role key

create index mfa_events_user_idx on public.mfa_events (user_id, created_at desc);
```

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 paths

### Flutterflow — fit 4/10, 2–4 hours

Best path for mobile 2FA: FlutterFlow supports Supabase Auth MFA via custom actions, and qr_flutter renders the enrollment QR code. TOTP flow is achievable with custom actions and pre-built UI screens.

1. In FlutterFlow, go to Settings → Supabase and confirm your project is connected; Supabase Auth TOTP MFA is enabled by default
2. Add qr_flutter to FlutterFlow via Settings → Pub Dependencies; this renders the QR code during enrollment
3. Create a Security Settings page with an 'Enable Two-Factor Authentication' button that triggers a custom action calling supabase.auth.mfa.enroll({ factorType: 'totp' }); store the returned secret and totp_uri in page state variables
4. Add a QrCodeWidget to the enrollment page bound to the totp_uri page state variable; below it show the secret in a copyable text field for manual entry
5. Create a 2FA Verification page with a 6-digit TextField (keyboardType: number, maxLength: 6) and a custom action that calls supabase.auth.mfa.challenge() then supabase.auth.mfa.verify(); navigate to this page after successful password login when enrolled factors exist
6. Generate 10 backup codes in a custom action on enrollment completion: create random 8-digit strings, bcrypt-hash them via a Supabase Edge Function, store in backup_codes table, show plaintext once on a 'Save your codes' screen

Limitations:

- FlutterFlow custom actions require Dart knowledge for the Supabase MFA API calls — enrollMfa, challengeMfa, and verifyMfa are not pre-built FlutterFlow actions
- SMS 2FA via Twilio requires a custom Dart HTTP request action calling a Supabase Edge Function — additional complexity beyond TOTP
- Dependency conflicts on FlutterFlow code export can break the supabase_flutter package version that the MFA APIs require

### Lovable — fit 2/10, 4–6 hours

Lovable is web-focused and not the right tool for native mobile 2FA flows. Useful only for building a companion web admin interface that manages 2FA settings, not for the mobile authenticator flow itself.

1. Use Lovable only if you are building a web companion app where admins can view user 2FA enrollment status or force 2FA for specific user roles
2. For the admin dashboard: prompt Lovable to show a user list with '2FA Enabled' / 'Not Enrolled' status by querying auth.mfa_factors via a Supabase Edge Function with service role key
3. Do not use Lovable for the TOTP enrollment QR code or verification flow — these require mobile native APIs that a web app cannot provide

Starter prompt:

```
Build a web admin page that shows 2FA enrollment status for all users. Use a Supabase Edge Function with service role key to query auth.mfa_factors and join with auth.users to show email, enrollment date, and factor type (totp/sms) per user. Admin can see which users have 2FA enabled and which do not. Add a 'Require 2FA' toggle per user role that sets a flag in user_privacy_settings — this flag is checked on login and redirects non-enrolled users to the 2FA enrollment screen. Show a count of backup codes remaining for each user (count where used_at IS NULL from backup_codes table). Do not build the TOTP enrollment QR code or mobile verification flow here — this admin view is web-only.
```

Limitations:

- Lovable is web-only — do not use for native mobile TOTP enrollment or QR code scanning
- Supabase Auth TOTP MFA UI for web requires specific AAL level enforcement that Lovable's generated code often misses
- TOTP verification in a web Lovable app will work but the enrollment QR code UX is suboptimal compared to native FlutterFlow or custom Flutter implementation

### Custom — fit 5/10, 1–2 weeks

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.

1. Add 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. Implement 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. Generate 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. Implement 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. Implement 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

Limitations:

- 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

## Gotchas

- **Clock drift causes every TOTP code to be rejected with no explanation** — 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** — 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** — 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** — 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** — 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

- Always allow ±1 TOTP time window in verification to tolerate clock drift — a window of 0 causes random failures that users cannot diagnose
- Hash backup codes with bcrypt before storage — treat them as passwords, not data, because they are credentials that bypass your second factor
- Enforce 2FA verification after password reset, not just after initial password login — password reset is a common attack vector against 2FA-protected accounts
- Generate backup codes and show them exactly once at enrollment, with a prominent and impossible-to-ignore warning that they cannot be retrieved again
- Use flutter_secure_storage for trusted device tokens on mobile — localStorage and SharedPreferences are cleared on app reinstall and app data wipe
- 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
- 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
- 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

## 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.

---

Source: https://www.rapidevelopers.com/app-features/two-factor-authentication
© RapidDev — https://www.rapidevelopers.com/app-features/two-factor-authentication
