rembrembdocs

Multi-Factor Authentication (TOTP)


How does app authenticator multi-factor authentication work?#

App Authenticator (TOTP) multi-factor authentication involves a timed one-time password generated from an authenticator app in the control of users. It uses a QR Code which to transmit a shared secret used to generate a One Time Password. A user can scan a QR code with their phone to capture a shared secret required for subsequent authentication.

The use of a QR code was initially introduced by Google Authenticator but is now universally accepted by all authenticator apps. The QR code has an alternate representation in URI form following the otpauth scheme such as: otpauth://totp/supabase:alice@supabase.com?secret=<secret>&issuer=supabase which a user can manually input in cases where there is difficulty rendering a QR Code.

Below is a flow chart illustrating how the Enrollment, Challenge, and Verify APIs work in the context of MFA (TOTP).

Diagram showing the flow of Multi-Factor authentication

TOTP MFA API is free to use and is enabled on all Supabase projects by default.

Add enrollment flow#

An enrollment flow provides a UI for users to set up additional authentication factors. Most applications add the enrollment flow in two places within their app:

  1. Right after login or sign up. This lets users quickly set up MFA immediately after they log in or create an account. We recommend encouraging all users to set up MFA if that makes sense for your application. Many applications offer this as an opt-in step in an effort to reduce onboarding friction.
  2. From within a settings page. Allows users to set up, disable or modify their MFA settings.

Enrolling a factor for use with MFA takes three steps:

  1. Call supabase.auth.mfa.enroll(). This method returns a QR code and a secret. Display the QR code to the user and ask them to scan it with their authenticator application. If they are unable to scan the QR code, show the secret in plain text which they can type or paste into their authenticator app.
  2. Calling the supabase.auth.mfa.challenge() API. This prepares Supabase Auth to accept a verification code from the user and returns a challenge ID. In the case of Phone MFA this step also sends the verification code to the user.
  3. Calling the supabase.auth.mfa.verify() API. This verifies that the user has indeed added the secret from step (1) into their app and is working correctly. If the verification succeeds, the factor immediately becomes active for the user account. If not, you should repeat steps 2 and 3.

Example: React#

Below is an example that creates a new EnrollMFA component that illustrates the important pieces of the MFA enrollment flow.

1/**2 * EnrollMFA shows a simple enrollment dialog. When shown on screen it calls3 * the `enroll` API. Each time a user clicks the Enable button it calls the4 * `challenge` and `verify` APIs to check if the code provided by the user is5 * valid.6 * When enrollment is successful, it calls `onEnrolled`. When the user clicks7 * Cancel the `onCancelled` callback is called.8 */9export function EnrollMFA({10  onEnrolled,11  onCancelled,12}: {13  onEnrolled: () => void14  onCancelled: () => void15}) {16  const [factorId, setFactorId] = useState('')17  const [qr, setQR] = useState('') // holds the QR code image SVG18  const [verifyCode, setVerifyCode] = useState('') // contains the code entered by the user19  const [error, setError] = useState('') // holds an error message2021  const onEnableClicked = () => {22    setError('')23    ;(async () => {24      const challenge = await supabase.auth.mfa.challenge({ factorId })25      if (challenge.error) {26        setError(challenge.error.message)27        throw challenge.error28      }2930      const challengeId = challenge.data.id3132      const verify = await supabase.auth.mfa.verify({33        factorId,34        challengeId,35        code: verifyCode,36      })37      if (verify.error) {38        setError(verify.error.message)39        throw verify.error40      }4142      onEnrolled()43    })()44  }4546  useEffect(() => {47    ;(async () => {48      const { data, error } = await supabase.auth.mfa.enroll({49        factorType: 'totp',50      })51      if (error) {52        throw error53      }5455      setFactorId(data.id)5657      // Supabase Auth returns an SVG QR code which you can convert into a data58      // URL that you can place in an <img> tag.59      setQR(data.totp.qr_code)60    })()61  }, [])6263  return (64    <>65      {error && <div className="error">{error}</div>}66      <img src={qr} />67      <input68        type="text"69        value={verifyCode}70        onChange={(e) => setVerifyCode(e.target.value.trim())}71      />72      <input type="button" value="Enable" onClick={onEnableClicked} />73      <input type="button" value="Cancel" onClick={onCancelled} />74    </>75  )76}

Add a challenge step to login#

Once a user has logged in via their first factor (email+password, magic link, one time password, social login etc.) you need to perform a check if any additional factors need to be verified.

This can be done by using the supabase.auth.mfa.getAuthenticatorAssuranceLevel() API. When the user signs in and is redirected back to your app, you should call this method to extract the user's current and next authenticator assurance level (AAL).

Therefore if you receive a currentLevel which is aal1 but a nextLevel of aal2, the user should be given the option to go through MFA.

Below is a table that explains the combined meaning.

Current Level

Next Level

Meaning

aal1

aal1

User does not have MFA enrolled.

aal1

aal2

User has an MFA factor enrolled but has not verified it.

aal2

aal2

User has verified their MFA factor.

aal2

aal1

User has disabled their MFA factor. (Stale JWT.)

Example: React#

Adding the challenge step to login depends heavily on the architecture of your app. However, a fairly common way to structure React apps is to have a large component (often named App) which contains most of the authenticated application logic.

This example will wrap this component with logic that will show an MFA challenge screen if necessary, before showing the full application. This is illustrated in the AppWithMFA example below.

1function AppWithMFA() {2  const [readyToShow, setReadyToShow] = useState(false)3  const [showMFAScreen, setShowMFAScreen] = useState(false)45  useEffect(() => {6    ;(async () => {7      try {8        const { data, error } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()9        if (error) {10          throw error11        }1213        console.log(data)1415        if (data.nextLevel === 'aal2' && data.nextLevel !== data.currentLevel) {16          setShowMFAScreen(true)17        }18      } finally {19        setReadyToShow(true)20      }21    })()22  }, [])2324  if (readyToShow) {25    if (showMFAScreen) {26      return <AuthMFA />27    }2829    return <App />30  }3132  return <></>33}

Below is the component that implements the challenge and verify logic.

1function AuthMFA() {2  const [verifyCode, setVerifyCode] = useState('')3  const [error, setError] = useState('')45  const onSubmitClicked = () => {6    setError('')7    ;(async () => {8      const factors = await supabase.auth.mfa.listFactors()9      if (factors.error) {10        throw factors.error11      }1213      const totpFactor = factors.data.totp[0]1415      if (!totpFactor) {16        throw new Error('No TOTP factors found!')17      }1819      const factorId = totpFactor.id2021      const challenge = await supabase.auth.mfa.challenge({ factorId })22      if (challenge.error) {23        setError(challenge.error.message)24        throw challenge.error25      }2627      const challengeId = challenge.data.id2829      const verify = await supabase.auth.mfa.verify({30        factorId,31        challengeId,32        code: verifyCode,33      })34      if (verify.error) {35        setError(verify.error.message)36        throw verify.error37      }38    })()39  }4041  return (42    <>43      <div>Please enter the code from your authenticator app.</div>44      {error && <div className="error">{error}</div>}45      <input46        type="text"47        value={verifyCode}48        onChange={(e) => setVerifyCode(e.target.value.trim())}49      />50      <input type="button" value="Submit" onClick={onSubmitClicked} />51    </>52  )53}

Frequently asked questions#