rembrembdocs

Multi-Factor Authentication (Phone)


How does phone multi-factor-authentication work?#

Phone multi-factor authentication involves a shared code generated by Supabase Auth and the end user. The code is delivered via a messaging channel, such as SMS or WhatsApp, and the user uses the code to authenticate to Supabase Auth.

The phone messaging configuration for MFA is shared with phone auth login. The same provider configuration that is used for phone login is used for MFA. You can also use the Send SMS Hook if you need to use an MFA (Phone) messaging provider different from what is supported natively.

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

Diagram showing the flow of Multi-Factor authentication

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 allows users quickly set up Multi Factor Authentication (MFA) post login or account creation. Where possible, encourage all users to set up MFA. 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.

As far as possible, maintain a generic flow that you can reuse in both cases with minor modifications.

Enrolling a factor for use with MFA takes three steps for phone MFA:

  1. Call supabase.auth.mfa.enroll().
  2. Calling the supabase.auth.mfa.challenge() API. This sends a code via SMS or WhatsApp and prepares Supabase Auth to accept a verification code from the user.
  3. Calling the supabase.auth.mfa.verify() API. supabase.auth.mfa.challenge() returns a challenge ID. This verifies that the code issued by Supabase Auth matches the code input by the user. 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.

1export function EnrollMFA({2  onEnrolled,3  onCancelled,4}: {5  onEnrolled: () => void6  onCancelled: () => void7}) {8  const [phoneNumber, setPhoneNumber] = useState('')9  const [factorId, setFactorId] = useState('')10  const [verifyCode, setVerifyCode] = useState('')11  const [error, setError] = useState('')12  const [challengeId, setChallengeId] = useState('')1314  const onEnableClicked = () => {15    setError('')16    ;(async () => {17      const verify = await auth.mfa.verify({18        factorId,19        challengeId,20        code: verifyCode,21      })22      if (verify.error) {23        setError(verify.error.message)24        throw verify.error25      }2627      onEnrolled()28    })()29  }30  const onEnrollClicked = async () => {31    setError('')32    try {33      const factor = await auth.mfa.enroll({34        phone: phoneNumber,35        factorType: 'phone',36      })37      if (factor.error) {38        setError(factor.error.message)39        throw factor.error40      }4142      setFactorId(factor.data.id)43    } catch (error) {44      setError('Failed to Enroll the Factor.')45    }46  }4748  const onSendOTPClicked = async () => {49    setError('')50    try {51      const challenge = await auth.mfa.challenge({ factorId })52      if (challenge.error) {53        setError(challenge.error.message)54        throw challenge.error55      }5657      setChallengeId(challenge.data.id)58    } catch (error) {59      setError('Failed to resend the code.')60    }61  }6263  return (64    <>65      {error && <div className="error">{error}</div>}66      <input67        type="text"68        placeholder="Phone Number"69        value={phoneNumber}70        onChange={(e) => setPhoneNumber(e.target.value.trim())}71      />72      <input73        type="text"74        placeholder="Verification Code"75        value={verifyCode}76        onChange={(e) => setVerifyCode(e.target.value.trim())}77      />78      <input type="button" value="Enroll" onClick={onEnrollClicked} />79      <input type="button" value="Submit Code" onClick={onEnableClicked} />80      <input type="button" value="Send OTP Code" onClick={onSendOTPClicked} />81      <input type="button" value="Cancel" onClick={onCancelled} />82    </>83  )84}

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('')4  const [factorId, setFactorId] = useState('')5  const [challengeId, setChallengeId] = useState('')6  const [phoneNumber, setPhoneNumber] = useState('')78  const startChallenge = async () => {9    setError('')10    try {11      const factors = await supabase.auth.mfa.listFactors()12      if (factors.error) {13        throw factors.error14      }1516      const phoneFactor = factors.data.phone[0]1718      if (!phoneFactor) {19        throw new Error('No phone factors found!')20      }2122      const factorId = phoneFactor.id23      setFactorId(factorId)24      setPhoneNumber(phoneFactor.phone)2526      const challenge = await supabase.auth.mfa.challenge({ factorId })27      if (challenge.error) {28        setError(challenge.error.message)29        throw challenge.error30      }3132      setChallengeId(challenge.data.id)33    } catch (error) {34      setError(error.message)35    }36  }3738  const verifyCode = async () => {39    setError('')40    try {41      const verify = await supabase.auth.mfa.verify({42        factorId,43        challengeId,44        code: verifyCode,45      })46      if (verify.error) {47        setError(verify.error.message)48        throw verify.error49      }50    } catch (error) {51      setError(error.message)52    }53  }5455  return (56    <>57      <div>Please enter the code sent to your phone.</div>58      {phoneNumber && <div>Phone number: {phoneNumber}</div>}59      {error && <div className="error">{error}</div>}60      <input61        type="text"62        value={verifyCode}63        onChange={(e) => setVerifyCode(e.target.value.trim())}64      />65      {!challengeId ? (66        <input type="button" value="Start Challenge" onClick={startChallenge} />67      ) : (68        <input type="button" value="Verify Code" onClick={verifyCode} />69      )}70    </>71  )72}

Security configuration#

Each code is valid for up to 5 minutes, after which a new one can be sent. Successive codes remain valid until expiry. When possible choose the longest code length acceptable to your use case, at a minimum of 6. This can be configured in the Authentication Settings.

Be aware that Phone MFA is vulnerable to SIM swap attacks where an attacker will call a mobile provider and ask to port the target's phone number to a new SIM card and then use the said SIM card to intercept an MFA code. Evaluate the your application's tolerance for such an attack. You can read more about SIM swapping attacks here

Pricing#

$0.1027 per hour ($75 per month) for the first project. $0.0137 per hour ($10 per month) for every additional project.

Plan

Project 1 per month

Project 2 per month

Project 3 per month

Pro

$75

$10

$10

Team

$75

$10

$10

Enterprise

Custom

Custom

Custom

For a detailed breakdown of how charges are calculated, refer to Manage Advanced MFA Phone usage.