The most surprising thing about Supabase’s TOTP MFA is that it doesn’t actually generate the TOTP secrets itself; it relies on the client-side to do the heavy lifting.

Let’s see it in action. Imagine a user initiating MFA setup in your app.

First, the client requests a secret from Supabase. This isn’t a new secret, but a reference to the user’s existing MFA configuration.

// On the client-side (e.g., React component)
import { supabase } from './supabaseClient';

async function setupMfa() {
  const { data, error } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel();

  if (error) {
    console.error('Error getting assurance level:', error.message);
    return;
  }

  // If assurance level is already sufficient, we don't need to set up TOTP
  if (data.assurance_level === 'aal2') {
    console.log('MFA already set up with sufficient assurance.');
    return;
  }

  // Request the TOTP secret and QR code URI from Supabase
  const { data: mfaData, error: mfaError } = await supabase.auth.mfa.totp.create({
    // This is just a placeholder, Supabase will return the actual QR code URI
    // The client doesn't generate the secret, it requests it.
  });

  if (mfaError) {
    console.error('Error creating TOTP:', mfaError.message);
    return;
  }

  console.log('MFA Setup Data:', mfaData);
  // mfaData.qr_code_uri will be something like:
  // "otpauth://totp/YourAppName:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=YourAppName"

  // You would then use a library like `qrcode.react` to display this QR code.
  // e.g., <QRCode value={mfaData.qr_code_uri} />
}

setupMfa();

The mfaData.qr_code_uri is the key here. This URI contains the TOTP secret (secret=JBSWY3DPEHPK3PXP) and issuer information, formatted for authenticator apps like Google Authenticator or Authy. Your frontend then renders this as a QR code, which the user scans.

Once the user scans the QR code and their authenticator app generates a code, they’ll enter it into your app to verify the setup.

// On the client-side (e.g., React component after user scans QR code)
import { supabase } from './supabaseClient';

async function verifyMfa(code: string) {
  const { error } = await supabase.auth.mfa.totp.verify({
    code: code, // The 6-digit code from the authenticator app
  });

  if (error) {
    console.error('Error verifying TOTP:', error.message);
    return;
  }

  console.log('TOTP verified successfully!');
  // Now the user's MFA is set up. The next time they log in, they'll be prompted for this code.
}

// Example usage:
// verifyMfa('123456');

After successful verification, Supabase internally marks the user’s account as having MFA enabled. When this user logs in again, Supabase will detect the MFA status and require a second factor before granting access.

The core problem Supabase’s MFA solves is preventing account takeover even if a user’s password is compromised. By requiring a time-based one-time password (TOTP) generated by a separate device or app, it adds a significant layer of security. The system manages the state of MFA for each user, determining if it’s enabled and requiring verification upon login.

Internally, Supabase’s Auth service handles the lifecycle of MFA. When create is called, it generates a unique, cryptographically secure secret and an issuer string, then packages them into the otpauth:// URI. This URI is not stored directly on Supabase’s user tables in a human-readable form; rather, it’s a transient piece of data used for the initial setup. The actual secret is then securely stored by Supabase, linked to the user’s account, and used for subsequent verification attempts. The verify endpoint takes the user-provided code, compares it against the expected TOTP derived from the stored secret and the current time, and updates the user’s MFA status upon successful match.

A key detail often overlooked is how Supabase handles the issuer in the otpauth:// URI. This issuer string is what appears above the generated code in authenticator apps (e.g., "YourAppName"). It’s crucial for helping users distinguish between multiple TOTP accounts if they use several services. If you don’t provide a clear and consistent issuer during the create call, your users might get confused, especially if they use the same email address across different applications. Supabase will default to a generic issuer if none is provided, which is less than ideal for user experience.

The next step after successfully setting up TOTP MFA is understanding how to enforce it during login, which involves configuring your supabase.auth.signInWithPassword calls to handle the MFA challenge.

Want structured learning?

Take the full Supabase course →