Auth

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

Phone MFA is part of the Auth Advanced MFA Add-on and costs an additional $75 per month for the first project in the organization and an additional $10 per month for additional projects.

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.

  • When the component appears on screen, the supabase.auth.mfa.enroll() API is called once to start the process of enrolling a new factor for the current user.
  • A challenge is created using the supabase.auth.mfa.challenge() API and the code from the user is submitted for verification using the supabase.auth.mfa.verify() challenge.
  • onEnabled is a callback that notifies the other components that enrollment has completed.
  • onCancelled is a callback that notifies the other components that the user has clicked the Cancel button.

_84
export function EnrollMFA({
_84
onEnrolled,
_84
onCancelled,
_84
}: {
_84
onEnrolled: () => void
_84
onCancelled: () => void
_84
}) {
_84
const [phoneNumber, setPhoneNumber] = useState('')
_84
const [factorId, setFactorId] = useState('')
_84
const [verifyCode, setVerifyCode] = useState('')
_84
const [error, setError] = useState('')
_84
const [challengeId, setChallengeId] = useState('')
_84
_84
const onEnableClicked = () => {
_84
setError('')
_84
;(async () => {
_84
const verify = await auth.mfa.verify({
_84
factorId,
_84
challengeId,
_84
code: verifyCode,
_84
})
_84
if (verify.error) {
_84
setError(verify.error.message)
_84
throw verify.error
_84
}
_84
_84
onEnrolled()
_84
})()
_84
}
_84
const onEnrollClicked = async () => {
_84
setError('')
_84
try {
_84
const factor = await auth.mfa.enroll({
_84
phone: phoneNumber,
_84
factorType: 'phone',
_84
})
_84
if (factor.error) {
_84
setError(factor.error.message)
_84
throw factor.error
_84
}
_84
_84
setFactorId(factor.data.id)
_84
} catch (error) {
_84
setError('Failed to Enroll the Factor.')
_84
}
_84
}
_84
_84
const onSendOTPClicked = async () => {
_84
setError('')
_84
try {
_84
const challenge = await auth.mfa.challenge({ factorId })
_84
if (challenge.error) {
_84
setError(challenge.error.message)
_84
throw challenge.error
_84
}
_84
_84
setChallengeId(challenge.data.id)
_84
} catch (error) {
_84
setError('Failed to resend the code.')
_84
}
_84
}
_84
_84
return (
_84
<>
_84
{error && <div className="error">{error}</div>}
_84
<input
_84
type="text"
_84
placeholder="Phone Number"
_84
value={phoneNumber}
_84
onChange={(e) => setPhoneNumber(e.target.value.trim())}
_84
/>
_84
<input
_84
type="text"
_84
placeholder="Verification Code"
_84
value={verifyCode}
_84
onChange={(e) => setVerifyCode(e.target.value.trim())}
_84
/>
_84
<input type="button" value="Enroll" onClick={onEnrollClicked} />
_84
<input type="button" value="Submit Code" onClick={onEnableClicked} />
_84
<input type="button" value="Send OTP Code" onClick={onSendOTPClicked} />
_84
<input type="button" value="Cancel" onClick={onCancelled} />
_84
</>
_84
)
_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 LevelNext LevelMeaning
aal1aal1User does not have MFA enrolled.
aal1aal2User has an MFA factor enrolled but has not verified it.
aal2aal2User has verified their MFA factor.
aal2aal1User 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.


_33
function AppWithMFA() {
_33
const [readyToShow, setReadyToShow] = useState(false)
_33
const [showMFAScreen, setShowMFAScreen] = useState(false)
_33
_33
useEffect(() => {
_33
;(async () => {
_33
try {
_33
const { data, error } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
_33
if (error) {
_33
throw error
_33
}
_33
_33
console.log(data)
_33
_33
if (data.nextLevel === 'aal2' && data.nextLevel !== data.currentLevel) {
_33
setShowMFAScreen(true)
_33
}
_33
} finally {
_33
setReadyToShow(true)
_33
}
_33
})()
_33
}, [])
_33
_33
if (readyToShow) {
_33
if (showMFAScreen) {
_33
return <AuthMFA />
_33
}
_33
_33
return <App />
_33
}
_33
_33
return <></>
_33
}

  • supabase.auth.mfa.getAuthenticatorAssuranceLevel() does return a promise. Don't worry, this is a very fast method (microseconds) as it rarely uses the network.
  • readyToShow only makes sure the AAL check completes before showing any application UI to the user.
  • If the current level can be upgraded to the next one, the MFA screen is shown.
  • Once the challenge is successful, the App component is finally rendered on screen.

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


_72
function AuthMFA() {
_72
const [verifyCode, setVerifyCode] = useState('')
_72
const [error, setError] = useState('')
_72
const [factorId, setFactorId] = useState('')
_72
const [challengeId, setChallengeId] = useState('')
_72
const [phoneNumber, setPhoneNumber] = useState('')
_72
_72
const startChallenge = async () => {
_72
setError('')
_72
try {
_72
const factors = await supabase.auth.mfa.listFactors()
_72
if (factors.error) {
_72
throw factors.error
_72
}
_72
_72
const phoneFactor = factors.data.phone[0]
_72
_72
if (!phoneFactor) {
_72
throw new Error('No phone factors found!')
_72
}
_72
_72
const factorId = phoneFactor.id
_72
setFactorId(factorId)
_72
setPhoneNumber(phoneFactor.phone)
_72
_72
const challenge = await supabase.auth.mfa.challenge({ factorId })
_72
if (challenge.error) {
_72
setError(challenge.error.message)
_72
throw challenge.error
_72
}
_72
_72
setChallengeId(challenge.data.id)
_72
} catch (error) {
_72
setError(error.message)
_72
}
_72
}
_72
_72
const verifyCode = async () => {
_72
setError('')
_72
try {
_72
const verify = await supabase.auth.mfa.verify({
_72
factorId,
_72
challengeId,
_72
code: verifyCode,
_72
})
_72
if (verify.error) {
_72
setError(verify.error.message)
_72
throw verify.error
_72
}
_72
} catch (error) {
_72
setError(error.message)
_72
}
_72
}
_72
_72
return (
_72
<>
_72
<div>Please enter the code sent to your phone.</div>
_72
{phoneNumber && <div>Phone number: {phoneNumber}</div>}
_72
{error && <div className="error">{error}</div>}
_72
<input
_72
type="text"
_72
value={verifyCode}
_72
onChange={(e) => setVerifyCode(e.target.value.trim())}
_72
/>
_72
{!challengeId ? (
_72
<input type="button" value="Start Challenge" onClick={startChallenge} />
_72
) : (
_72
<input type="button" value="Verify Code" onClick={verifyCode} />
_72
)}
_72
</>
_72
)
_72
}

  • You can extract the available MFA factors for the user by calling supabase.auth.mfa.listFactors(). Don't worry this method is also very quick and rarely uses the network.
  • If listFactors() returns more than one factor (or of a different type) you should present the user with a choice. For simplicity this is not shown in the example.
  • Phone numbers are unique per user. Users can only have one verified phone factor with a given phone number. Attempting to enroll a new phone factor alongside an existing verified factor with the same number will result in an error.
  • Each time the user presses the "Submit" button a new challenge is created for the chosen factor (in this case the first one)
  • On successful verification, the client library will refresh the session in the background automatically and finally call the onSuccess callback, which will show the authenticated App component on screen.

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.

Please 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. Please evaluate the your application's tolerance for such an attack. You can read more about SIM swapping attacks here