# How to Add Multi-Factor Authentication to Your App — Copy-Paste Prompts Included

- Tool: App Features
- Last updated: July 2026

## TL;DR

MFA means a user proves identity with at least two factors. For a web app, ship TOTP (authenticator app) plus email OTP as your two baseline options using Supabase Auth's built-in MFA module. With Lovable or V0 and a detailed prompt, you can have enrollment, verification, and step-up auth working in 4–8 hours. Running cost is near zero for TOTP; SMS adds roughly $0.10/verification if you choose that path.

## What Multi-Factor Authentication Actually Means for Your App

Multi-factor authentication asks users to prove identity with something they know (password) AND something they have (phone with authenticator app, or inbox for an OTP email). The key concept Supabase introduces is AAL — Authentication Assurance Level. AAL1 means the user passed password-only login. AAL2 means they also passed at least one enrolled MFA factor. Your app can check the current AAL level before granting access to sensitive routes or operations. Step-up auth takes this further: instead of requiring MFA only at login, you re-challenge on specific actions (delete account, export data, change payment method). This is what separates a robust MFA implementation from one that looks good until an attacker steals a session token.

## Anatomy of the Feature

Six components working together. Supabase Auth handles the factor storage internally; you build the UI flows and the AAL enforcement layer around it.

- **Supabase Auth MFA module** (backend): Supabase Auth provides enrollMfa(), challengeMfa(), and verifyMfa() methods. Each enrolled factor is stored as an enrolledFactor record tied to the user. The module tracks AAL — after password login the session is AAL1; after a successful MFA challenge it becomes AAL2. All factor storage is handled internally in auth.mfa_factors — you do not manage this table directly.
- **TOTP factor** (backend): RFC 6238 time-based 6-digit codes. Supabase Auth generates the TOTP secret server-side during enrollMfa(). The client renders it as a QR code using the qrcode.react React component or the qrcode npm package. The otpauth:// URI must include issuer name and user email for authenticator apps to display a meaningful account name.
- **Email OTP factor** (backend): A Supabase Edge Function generates a 6-digit code, hashes it, stores hash + expiry in the mfa_otp table, then delivers it via Resend or Supabase SMTP. Code expires in 10 minutes. The Edge Function enforces rate limiting — max 3 OTP requests per user per 10-minute window to prevent inbox flooding.
- **SMS OTP factor (optional)** (service): Twilio Verify API handles code generation, delivery, and verification in a single integration. Lovable's native Twilio connector makes wiring it to an Edge Function straightforward. Reserve SMS as a fallback for users who cannot use authenticator apps — TOTP is free, SMS costs $0.05/SMS sent plus $0.05/verification (approx).
- **Factor enrollment UI** (ui): A multi-step wizard built with shadcn/ui Dialog and a Stepper component. Step 1: choose factor type (TOTP or email OTP). Step 2: setup — show QR code and manual secret for TOTP, or confirm email address for email OTP. Step 3: enter the live code to verify enrollment before saving. Step 4: show and download backup codes. Each step must validate before allowing Next.
- **Step-up auth gate** (ui): A React component (or Next.js middleware) that reads the current AAL level from Supabase Auth before allowing access to sensitive routes or triggering sensitive mutations. If the current session is AAL1 but the action requires AAL2, it intercepts and shows a fresh MFA challenge. Applies to /settings/security, delete account, export data, change email, and any payment modifications.

## Data model

Supabase Auth manages enrolled factors in auth.mfa_factors internally. You need three additional tables: mfa_otp for email OTP codes, backup_codes for recovery, and an audit_log for compliance. Run this in the Supabase SQL editor:

```sql
create table public.mfa_otp (
  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,
  factor_type text not null default 'email',
  expires_at timestamptz not null,
  used_at timestamptz,
  created_at timestamptz not null default now()
);

alter table public.mfa_otp enable row level security;

create policy "Users can read own OTP rows"
  on public.mfa_otp for select
  using (auth.uid() = user_id);

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,
  used_at timestamptz,
  created_at timestamptz not null default now()
);

alter table public.backup_codes enable row level security;

create policy "Users can read own backup codes"
  on public.backup_codes for select
  using (auth.uid() = user_id);

create table public.mfa_audit_log (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  event text not null,
  factor_type text,
  success boolean not null default true,
  ip_address text,
  created_at timestamptz not null default now()
);

alter table public.mfa_audit_log enable row level security;

create policy "Users can read own audit log"
  on public.mfa_audit_log for select
  using (auth.uid() = user_id);

create index mfa_otp_user_expires_idx on public.mfa_otp (user_id, expires_at);
create index backup_codes_user_idx on public.backup_codes (user_id) where used_at is null;
create index mfa_audit_user_idx on public.mfa_audit_log (user_id, created_at desc);
```

The backup_codes partial index (WHERE used_at IS NULL) makes unused-code lookups fast even after a user has consumed most of their recovery codes over time. Insert into mfa_audit_log via an Edge Function using the service role key — the anon key cannot bypass RLS for audit writes from other users' sessions.

## Build paths

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

Lovable can build the MFA enrollment UI and TOTP flow with Supabase Auth, but the multi-step wizard and step-up auth logic require an extremely specific prompt and expect multiple refinement iterations. Best for founders comfortable reviewing generated code.

1. Connect Lovable Cloud so Supabase Auth is provisioned; verify TOTP MFA is enabled in Supabase Dashboard under Authentication → MFA
2. Paste the prompt below into Agent Mode — be explicit about every step of the enrollment wizard and the step-up auth requirement
3. After generation, navigate to each step of the enrollment wizard and verify the QR code scans correctly in Google Authenticator before marking it done
4. Test step-up auth by accessing /settings/security with an AAL1 session and confirming the MFA challenge appears
5. Test email OTP by triggering a send and checking that a second request within 10 minutes is rejected with a rate-limit message
6. Deploy and test all flows on the published URL — the Lovable preview iframe cannot test MFA properly

Starter prompt:

```
Build a multi-factor authentication system using Supabase Auth MFA. Required factor types: TOTP authenticator app (via Supabase Auth enrollMfa) and email OTP (via a Supabase Edge Function that hashes the 6-digit code, stores it in a mfa_otp table with 10-minute expiry, and sends via Resend). MFA enrollment wizard: a shadcn/ui Dialog with a Stepper — step 1: choose TOTP or email OTP; step 2: TOTP shows QR code using qrcode.react with the otpauth:// URI in format otpauth://totp/AppName:email?secret=SECRET&issuer=AppName plus a 'copy secret' manual fallback; step 3: enter the live 6-digit code to verify before saving the factor; step 4: show 10 generated backup codes (displayed once, stored hashed with bcrypt in backup_codes table). Email OTP: Edge Function only, rate-limited to 3 sends per user per 10 minutes enforced by DB query. Step-up authentication: wrap Delete Account, Change Email, and Export Data actions with an AAL check — if supabase.auth.mfa.getAuthenticatorAssuranceLevel() shows currentLevel is 'aal1' and nextLevel requires 'aal2', show a fresh MFA challenge before proceeding. Factor management page at /settings/security listing enrolled factors with a Remove button. Admin setting: checkbox on the user_roles admin panel to require MFA for all admin-role users. Audit log: write success and failure events to mfa_audit_log via Edge Function with service role key. Recovery codes: check used_at IS NULL before accepting; set used_at = now() atomically with auth success.
```

Limitations:

- High looping risk on the multi-step enrollment wizard — AI often skips the verification step (step 3) and enrolls factors without live code confirmation
- Step-up auth for sensitive mutations is frequently missed in first generation; review every Edge Function to confirm AAL check is present
- AI may generate email OTP send logic in a React component without rate limiting — always verify it runs in an Edge Function
- Lovable preview cannot be used to test MFA; always test on the published URL

### V0 — fit 4/10, 4–8 hours

V0 generates clean Next.js TypeScript for MFA orchestration logic and is better than Lovable at multi-step server-side flows. Pairs naturally with Supabase Auth via Next.js middleware for AAL enforcement.

1. Prompt V0 with the full spec below; use the Vars panel to set NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, and RESEND_API_KEY
2. Run the mfa_otp, backup_codes, and mfa_audit_log SQL schema from this page in the Supabase SQL editor
3. Enable Supabase Auth MFA in the Supabase Dashboard under Authentication → MFA → Enable TOTP
4. Add Next.js middleware that reads the Supabase AAL level and redirects AAL1 sessions away from protected routes
5. Test the TOTP QR code with Google Authenticator and Authy to confirm the URI format is correct
6. Deploy to Vercel and run the full enrollment → step-up → recovery flow before shipping

Starter prompt:

```
Build a multi-factor authentication system in Next.js App Router using Supabase Auth MFA. Factor types: TOTP (Supabase enrollMfa / challengeMfa / verifyMfa) and email OTP (custom Edge Function with hashed 6-digit code stored in mfa_otp table, 10-minute expiry, 3-request rate limit per 10-minute window enforced by DB query, sent via Resend). Enrollment wizard at /settings/security/enroll as a Next.js page with shadcn/ui Stepper: step 1 choose factor type; step 2 TOTP shows QR code via qrcode.react with URI otpauth://totp/AppName:{{email}}?secret={{secret}}&issuer=AppName plus copyable raw secret; step 3 enter live 6-digit code to verify; step 4 show 10 backup codes one time only (hash with bcrypt before storing in backup_codes table). Next.js middleware in middleware.ts: call supabase.auth.mfa.getAuthenticatorAssuranceLevel() and redirect to /mfa/challenge if nextLevel is aal2 and currentLevel is aal1 for routes in /settings/security, /account/delete, /account/export. Sensitive Server Actions for delete and export: re-check AAL inside the action, return error if aal1. Factor management page listing enrolled factors from supabase.auth.mfa.listFactors() with a Remove button per factor. Admin enforcement: in the admin user table, a toggle to flag user as mfa_required; a nightly Edge Function checks flagged users without enrolled factors and sends a reminder email. Recovery codes: verify used_at IS NULL before accepting; set used_at atomically. Audit log: write to mfa_audit_log via supabase service role in every auth Edge Function.
```

Limitations:

- V0 does not auto-provision Supabase MFA settings — you must enable TOTP in the Supabase Dashboard manually
- AAL level checking logic in middleware is often incomplete in first generation; test with an AAL1 session explicitly
- First-gen code often misses the step-up auth requirement for sensitive Server Actions — verify each destructive action checks AAL independently
- Multiple prompt iterations expected; budget 2–3 refinement rounds before the full flow is solid

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

Full control over all factor types, AAL enforcement, adaptive MFA triggers, compliance audit logs, and recovery flows. Required for apps with NIST 800-63B, SOC 2, or HIPAA requirements.

1. Implement all Supabase Auth MFA calls (enrollMfa, challengeMfa, verifyMfa) with full error handling for each factor type and failure mode
2. Write Next.js middleware that enforces AAL at the route level with explicit per-route configuration
3. Build step-up auth as a reusable React context that any component or Server Action can invoke
4. Implement adaptive MFA: evaluate risk signals (new device fingerprint, VPN detection, unusual geography) and only challenge when risk score exceeds threshold
5. Write automated compliance audit log pipeline: every MFA event written to append-only table with IP, user agent, and factor type

Limitations:

- Security-critical code that requires expert review before shipping to production — budget for external penetration testing
- Adaptive MFA risk scoring requires a data pipeline and decision model that adds weeks of engineering beyond basic MFA

## Gotchas

- **MFA enrollment exists but AAL level is never checked** — Lovable and V0 frequently generate the enrollment wizard and verification screens but never add the route-level AAL check. Users enroll MFA and feel secure — but they can skip MFA challenge entirely by navigating directly to /dashboard after a password-only login. The AAL is aal1 and no code stops them. Fix: After generating MFA UI, explicitly verify the AAL check: call supabase.auth.mfa.getAuthenticatorAssuranceLevel() on every protected page load and redirect to the MFA challenge route if currentLevel is aal1 and nextLevel is aal2. Add this to Next.js middleware for blanket route enforcement.
- **Step-up auth missing from sensitive mutations** — The login MFA challenge is implemented correctly, but destructive actions — delete account, change email, export all data — don't re-challenge. A stolen session cookie allows an attacker to wipe the account without ever touching the MFA flow. Fix: Every sensitive Edge Function or Server Action must independently check auth.jwt() ->> 'aal' = 'aal2' before proceeding. A UI gate alone is insufficient — direct API calls bypass it. Make the AAL check the first line of every sensitive function.
- **Email OTP sent from client without rate limiting** — AI generates OTP send logic inside a React onClick handler or a client component. There is nothing preventing an attacker from calling the send endpoint hundreds of times, flooding a victim's inbox and potentially triggering Resend's abuse detection, which suspends your account. Fix: Route all OTP sends through a Supabase Edge Function. Inside the function, query mfa_otp for requests in the last 10 minutes from that user_id. If count >= 3, return a 429 error without sending. The rate limit enforcement must live in the database, not the client.
- **Recovery codes remain valid after first use** — AI generates recovery code storage and verification but omits the used_at check. A recovery code, once used to regain access, stays in the table as a valid code. Anyone who observed the code being entered can reuse it indefinitely. Fix: Before accepting a recovery code, query WHERE code_hash = hash AND used_at IS NULL. If no row matches, reject. On success, SET used_at = now() in the same database transaction as the session grant — never in a separate call that might not complete.
- **QR code URI encoding breaks authenticator apps** — The otpauth:// URI that drives QR code enrollment is very format-sensitive. AI sometimes generates the URI with spaces not encoded as %20, colons not percent-encoded in the account name, or the issuer parameter missing. Google Authenticator silently fails to add the account with no error message shown to the user. Fix: Use the exact format otpauth://totp/IssuerName:user@email.com?secret=BASE32SECRET&issuer=IssuerName. Never hand-roll the URI — let the Supabase Auth enrollMfa() return value provide the URI string. Test by scanning with Google Authenticator AND Authy before shipping; they fail differently.

## Best practices

- Make TOTP the default recommended factor — it is free to run, phishing-resistant, and works offline on the user's phone without cell service
- Show backup codes only once at enrollment and display a prominent warning: 'Save these now — you cannot view them again'
- Implement step-up auth for every destructive or high-value action, not just the login flow; a stolen session token should never be enough to delete an account
- Rate-limit OTP sends in the Edge Function at the database level, not in client code — client-side rate limiting is trivially bypassed
- Write every MFA event (enroll, verify success, verify failure, backup code use) to the audit log with timestamp and factor type; compliance requirements will ask for this
- When a user has no enrolled factors and MFA is required for their role, send a weekly reminder email with a deadline before locking the account — not a hard immediate lockout
- Test the full enrollment flow with both Google Authenticator and Authy before shipping — QR code parsing behavior differs between the two apps
- Provide a 'remove factor' path in settings so users who change phones are not locked out; require step-up auth before allowing factor removal

## Frequently asked questions

### What is the difference between 2FA and MFA?

Two-factor authentication (2FA) is a specific case of multi-factor authentication using exactly two factors. MFA is the broader term that includes systems with three or more factors, adaptive challenges, or multiple factor types the user can choose between. For most web apps, 2FA and MFA are functionally the same — you prompt for one additional factor after the password.

### Is an authenticator app or SMS more secure for MFA?

Authenticator apps (TOTP) are more secure. SMS is vulnerable to SIM-swap attacks where an attacker convinces a carrier to transfer your phone number. TOTP codes are generated offline on the user's device and never transmitted over a carrier network. For most apps, TOTP should be the default recommendation with SMS as an optional fallback.

### Can I require MFA for admins but keep it optional for regular users?

Yes. Store a required_mfa flag per user or role. In your middleware or Edge Functions, check the AAL requirement based on the user's role. Admin users who haven't reached AAL2 get redirected to the MFA challenge; regular users do not. Implement the enforcement in the database and Edge Functions, not just in the UI.

### What happens if a user loses access to all their MFA factors?

This is why backup codes exist. At enrollment, generate 10 single-use recovery codes and instruct the user to save them offline. If backup codes are also lost, you need a manual identity verification process — typically an admin override that requires the user to submit a support ticket and verify identity before codes are regenerated. Design this before you ship MFA, not after the first lockout.

### What is step-up authentication and do I need it?

Step-up auth means requiring a fresh MFA challenge for specific sensitive actions, even if the user already passed MFA at login. The login session might be hours old. Requiring re-authentication before deleting an account, changing the primary email, or exporting all data means a stolen cookie alone is not enough to do damage. If your app handles financial data, user-generated content with real value, or business records, you need step-up auth.

### Which MFA method should I implement first for my MVP?

TOTP authenticator app. It costs nothing to run, works without cell service, is phishing-resistant, and Supabase Auth supports it natively. Email OTP is a reasonable second option for users who resist installing an authenticator app. Skip SMS for your MVP — the $0.10/verification adds up and SIM-swap risk makes it the weakest option.

### Does MFA slow down the login experience significantly?

Only if you require it on every login. Most apps show the MFA challenge once per new device and skip it for known devices using a trust token. The extra 10–15 seconds to open an authenticator app is a well-accepted tradeoff for security-conscious users. Make TOTP the default since users can typically retrieve a code faster than waiting for an SMS.

### Can I add MFA to an existing app without forcing all users to re-enroll?

Yes. Roll it out as optional first: add the enrollment flow and let users opt in. After adoption climbs, set a deadline for admin-role users to enroll before enforcement kicks in. Never force immediate hard enrollment on existing users who are already mid-session — it causes lockouts and support tickets. The Supabase Auth MFA enrollment flow is additive and does not require existing sessions to be invalidated.

---

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