Auth

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

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.

  • 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.
  • This API returns a QR code in the SVG format, which is shown on screen using a normal <img> tag by encoding the SVG as a data URL.
  • Once the user has scanned the QR code with their authenticator app, they should enter the verification code within the verifyCode input field and click on Enable.
  • 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.

_76
/**
_76
* EnrollMFA shows a simple enrollment dialog. When shown on screen it calls
_76
* the `enroll` API. Each time a user clicks the Enable button it calls the
_76
* `challenge` and `verify` APIs to check if the code provided by the user is
_76
* valid.
_76
* When enrollment is successful, it calls `onEnrolled`. When the user clicks
_76
* Cancel the `onCancelled` callback is called.
_76
*/
_76
export function EnrollMFA({
_76
onEnrolled,
_76
onCancelled,
_76
}: {
_76
onEnrolled: () => void
_76
onCancelled: () => void
_76
}) {
_76
const [factorId, setFactorId] = useState('')
_76
const [qr, setQR] = useState('') // holds the QR code image SVG
_76
const [verifyCode, setVerifyCode] = useState('') // contains the code entered by the user
_76
const [error, setError] = useState('') // holds an error message
_76
_76
const onEnableClicked = () => {
_76
setError('')
_76
;(async () => {
_76
const challenge = await supabase.auth.mfa.challenge({ factorId })
_76
if (challenge.error) {
_76
setError(challenge.error.message)
_76
throw challenge.error
_76
}
_76
_76
const challengeId = challenge.data.id
_76
_76
const verify = await supabase.auth.mfa.verify({
_76
factorId,
_76
challengeId,
_76
code: verifyCode,
_76
})
_76
if (verify.error) {
_76
setError(verify.error.message)
_76
throw verify.error
_76
}
_76
_76
onEnrolled()
_76
})()
_76
}
_76
_76
useEffect(() => {
_76
;(async () => {
_76
const { data, error } = await supabase.auth.mfa.enroll({
_76
factorType: 'totp',
_76
})
_76
if (error) {
_76
throw error
_76
}
_76
_76
setFactorId(data.id)
_76
_76
// Supabase Auth returns an SVG QR code which you can convert into a data
_76
// URL that you can place in an <img> tag.
_76
setQR(data.totp.qr_code)
_76
})()
_76
}, [])
_76
_76
return (
_76
<>
_76
{error && <div className="error">{error}</div>}
_76
<img src={qr} />
_76
<input
_76
type="text"
_76
value={verifyCode}
_76
onChange={(e) => setVerifyCode(e.target.value.trim())}
_76
/>
_76
<input type="button" value="Enable" onClick={onEnableClicked} />
_76
<input type="button" value="Cancel" onClick={onCancelled} />
_76
</>
_76
)
_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 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.


_53
function AuthMFA() {
_53
const [verifyCode, setVerifyCode] = useState('')
_53
const [error, setError] = useState('')
_53
_53
const onSubmitClicked = () => {
_53
setError('')
_53
;(async () => {
_53
const factors = await supabase.auth.mfa.listFactors()
_53
if (factors.error) {
_53
throw factors.error
_53
}
_53
_53
const totpFactor = factors.data.totp[0]
_53
_53
if (!totpFactor) {
_53
throw new Error('No TOTP factors found!')
_53
}
_53
_53
const factorId = totpFactor.id
_53
_53
const challenge = await supabase.auth.mfa.challenge({ factorId })
_53
if (challenge.error) {
_53
setError(challenge.error.message)
_53
throw challenge.error
_53
}
_53
_53
const challengeId = challenge.data.id
_53
_53
const verify = await supabase.auth.mfa.verify({
_53
factorId,
_53
challengeId,
_53
code: verifyCode,
_53
})
_53
if (verify.error) {
_53
setError(verify.error.message)
_53
throw verify.error
_53
}
_53
})()
_53
}
_53
_53
return (
_53
<>
_53
<div>Please enter the code from your authenticator app.</div>
_53
{error && <div className="error">{error}</div>}
_53
<input
_53
type="text"
_53
value={verifyCode}
_53
onChange={(e) => setVerifyCode(e.target.value.trim())}
_53
/>
_53
<input type="button" value="Submit" onClick={onSubmitClicked} />
_53
</>
_53
)
_53
}

  • 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.
  • Each time the user presses the "Submit" button a new challenge is created for the chosen factor (in this case the first one) and it is immediately verified. Any errors are displayed to the user.
  • 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.

Frequently asked questions