MFA verification hook outputs
The MFA verification hook must return an object with decision (string, either 'reject' to deny the verification attempt and log the user out of all active sessions, or 'continue' to use default Supabase Auth behavior) and message (string, the message to show the user if the decision was 'reject').
MFA verification hook use cases
MFA verification hooks can be used to limit the number of verification attempts performed over a period of time, sign out users who have too many invalid verification attempts, or count, rate limit, or ban sign-ins.
Rate limit MFA verification attempts example
This SQL example creates a public.mfa_failed_verification_attempts table with user_id (uuid), factor_id (uuid), and last_failed_at (timestamp, defaults to now()). The primary key is (user_id, factor_id). The hook function public.hook_mfa_verification_attempt checks if a valid TOTP code was provided and returns decision 'continue'. For invalid attempts, it queries the table to find the last failed attempt time. If the last attempt was less than 2 seconds ago, it returns an error object with http_code 429 and message 'Please wait a moment before trying again.'. Otherwise, it inserts or updates the failed attempt record and returns decision 'continue'. Access is granted to supabase_auth_admin and revoked from authenticated, anon, and public roles.
MFA verification hook inputs
Supabase Auth sends a payload to the MFA verification hook with these fields: factor_id (string, unique identifier for the MFA factor being verified), factor_type (string, either 'totp' or 'phone'), user_id (string, unique identifier for the user), and valid (boolean, whether the verification attempt was valid - for TOTP, true means the six digit code was correct, false means incorrect).
Phone MFA overview and delivery methods
Phone multi-factor authentication involves a shared code generated by Supabase Auth and sent to the end user via a messaging channel such as SMS or WhatsApp. The user uses the code to authenticate to Supabase Auth. The phone messaging configuration for MFA is shared with phone auth login, meaning the same provider configuration used for phone login is used for MFA.
MFA enrollment flow steps for phone
Enrolling a phone MFA factor takes three steps: (1) Call supabase.auth.mfa.enroll() with the phone number and factorType 'phone', (2) Call supabase.auth.mfa.challenge() to send a code via SMS or WhatsApp and prepare Supabase Auth to accept verification, which returns a challenge ID, (3) Call supabase.auth.mfa.verify() with the factorId, challengeId, and the code entered by the user to verify the code matches. On success the factor becomes active. On failure, repeat steps 2 and 3.
AAL1 and AAL2 session levels in MFA flow
Sessions have two authenticator assurance levels (AAL). AAL1 represents authentication via the first factor (email, password, magic link, etc.). AAL2 represents authentication after verifying an additional MFA factor. During setup flow, a session starts at AAL1, then upgrades to AAL2 after MFA verification. During login flow, signing in upgrades the session to AAL1, and MFA verification upgrades it to AAL2.
Phone uniqueness and enrollment restrictions
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.
Phone MFA SIM swap attack vulnerability
Phone MFA is vulnerable to SIM swap attacks where an attacker calls a mobile provider to port the target's phone number to a new SIM card and then uses the SIM card to intercept an MFA code. Applications should evaluate their tolerance for such attacks when implementing phone MFA.
Phone MFA code validity period
Each code issued for phone MFA is valid for up to 5 minutes, after which a new one can be sent. Successive codes remain valid until expiry. The code length can be configured in the Authentication Settings, with a minimum recommended length of 6 characters. Choose the longest code length acceptable to your use case.
MFA flow architecture with AAL checking
Example React architecture for wrapping authenticated application with MFA checking:
function AppWithMFA() {
const [readyToShow, setReadyToShow] = useState(false)
const [showMFAScreen, setShowMFAScreen] = useState(false)
useEffect(() => {
;(async () => {
try {
const { data, error } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
if (error) {
throw error
}
console.log(data)
if (data.nextLevel === 'aal2' && data.nextLevel !== data.currentLevel) {
setShowMFAScreen(true)
}
} finally {
setReadyToShow(true)
}
})()
}, [])
if (readyToShow) {
if (showMFAScreen) {
return <AuthMFA />
}
return <App />
}
return <></>
}
Phone MFA challenge and verify example (React)
Example React component for MFA challenge and verification during login:
function AuthMFA() {
const [verifyCode, setVerifyCode] = useState('')
const [error, setError] = useState('')
const [factorId, setFactorId] = useState('')
const [challengeId, setChallengeId] = useState('')
const [phoneNumber, setPhoneNumber] = useState('')
const startChallenge = async () => {
setError('')
try {
const factors = await supabase.auth.mfa.listFactors()
if (factors.error) {
throw factors.error
}
const phoneFactor = factors.data.phone[0]
if (!phoneFactor) {
throw new Error('No phone factors found!')
}
const factorId = phoneFactor.id
setFactorId(factorId)
setPhoneNumber(phoneFactor.phone)
const challenge = await supabase.auth.mfa.challenge({ factorId })
if (challenge.error) {
setError(challenge.error.message)
throw challenge.error
}
setChallengeId(challenge.data.id)
} catch (error) {
setError(error.message)
}
}
const verifyCode = async () => {
setError('')
try {
const verify = await supabase.auth.mfa.verify({
factorId,
challengeId,
code: verifyCode,
})
if (verify.error) {
setError(verify.error.message)
throw verify.error
}
} catch (error) {
setError(error.message)
}
}
return (
<>
<div>Please enter the code sent to your phone.</div>
{phoneNumber && <div>Phone number: {phoneNumber}</div>}
{error && <div className="error">{error}</div>}
<input
type="text"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value.trim())}
/>
{!challengeId ? (
<input type="button" value="Start Challenge" onClick={startChallenge} />
) : (
<input type="button" value="Verify Code" onClick={verifyCode} />
)}
</>
)
}
Phone MFA enrollment example (React)
Example React component for MFA enrollment:
export function EnrollMFA({
onEnrolled,
onCancelled,
}: {
onEnrolled: () => void
onCancelled: () => void
}) {
const [phoneNumber, setPhoneNumber] = useState('')
const [factorId, setFactorId] = useState('')
const [verifyCode, setVerifyCode] = useState('')
const [error, setError] = useState('')
const [challengeId, setChallengeId] = useState('')
const onEnableClicked = () => {
setError('')
;(async () => {
const verify = await auth.mfa.verify({
factorId,
challengeId,
code: verifyCode,
})
if (verify.error) {
setError(verify.error.message)
throw verify.error
}
onEnrolled()
})()
}
const onEnrollClicked = async () => {
setError('')
try {
const factor = await auth.mfa.enroll({
phone: phoneNumber,
factorType: 'phone',
})
if (factor.error) {
setError(factor.error.message)
throw factor.error
}
setFactorId(factor.data.id)
} catch (error) {
setError('Failed to Enroll the Factor.')
}
}
const onSendOTPClicked = async () => {
setError('')
try {
const challenge = await auth.mfa.challenge({ factorId })
if (challenge.error) {
setError(challenge.error.message)
throw challenge.error
}
setChallengeId(challenge.data.id)
} catch (error) {
setError('Failed to resend the code.')
}
}
return (
<>
{error && <div className="error">{error}</div>}
<input
type="text"
placeholder="Phone Number"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value.trim())}
/>
<input
type="text"
placeholder="Verification Code"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value.trim())}
/>
<input type="button" value="Enroll" onClick={onEnrollClicked} />
<input type="button" value="Submit Code" onClick={onEnableClicked} />
<input type="button" value="Send OTP Code" onClick={onSendOTPClicked} />
<input type="button" value="Cancel" onClick={onCancelled} />
</>
)
}
AMR claim in access token
Access tokens issued by Supabase Auth contain an 'amr' (Authentication Methods Reference) claim. It is an array of objects that indicate what authentication methods the user has used so far. Each entry contains a 'method' and a 'timestamp' field, with entries ordered most recent method first.
Authenticator Assurance Level (AAL) definition
Authenticator Assurance Level is a standard measure of the assurance of a user's identity that Supabase Auth has for a particular session. There are two levels: AAL1 means the user's identity was verified using a conventional login method such as email+password, magic link, one-time password, phone auth or social login. AAL2 means the user's identity was additionally verified using at least one second factor, such as a TOTP code or One-Time Password code.
AAL claim in JWT
The authenticator assurance level is encoded in the 'aal' claim in the JWT associated with the user. By decoding this value, applications can create custom authorization rules in frontend, backend, and database that will enforce MFA policies. JWTs without an 'aal' claim are at the aal1 level.
Four steps to add MFA to an app
Adding MFA to an app involves these four steps: (1) Add enrollment flow - provide a UI within the app where users can set up MFA, either right after sign-up or as part of a separate flow in settings. (2) Add unenroll flow - support a UI through which users can see existing devices and unenroll devices which are no longer relevant. (3) Add challenge step to login - if a user has set up MFA, the app's login flow needs to present a challenge screen asking users to prove they have access to the additional factor. (4) Enforce rules for MFA logins - once users have a way to enroll and log in with MFA, enforce authorization rules across the app on the frontend, backend, API servers or Row-Level Security policies.
Unenroll MFA flow
When a user unenrolls a factor, call supabase.auth.mfa.unenroll() with the ID of the factor. The unenroll process is the same for both Phone and TOTP factors. An unenroll flow provides a UI for users to manage and unenroll factors linked to their accounts, typically via a factor management page where users can view and unlink selected factors.
unenroll() API call example
To unenroll a factor, use the following code:
supabase.auth.mfa.unenroll({ factorId: 'd30fd651-184e-4748-a928-0a4b9be1d429' })
This unenrolls a factor with the specified factorId.
AAL downgrade after unenroll
Unenrolling a factor will downgrade the assurance level from aal2 to aal1 only after the refresh interval has lapsed. For an immediate downgrade from aal2 to aal1 after unenrolling, one needs to manually call refreshSession().
Three MFA enforcement strategies
There are three ways to enforce MFA in applications: (1) Enforce for all users (new and existing) - any user account will have to enroll MFA to continue using the app, and the application will not allow access without going through MFA first. (2) Enforce for new users only - only new users will be forced to enroll MFA while old users will be encouraged to do so, and the application will not allow access for new users without going through MFA first. (3) Enforce only for users that have opted-in - users that want MFA can enroll in it and the application will not allow access without going through MFA first for those users.
Server-side rendering MFA enforcement
When using the Supabase JavaScript library in a server-side rendering context, always create a new object for each request to prevent accidentally rendering and serving content belonging to different users. You can use the supabase.auth.mfa.getAuthenticatorAssuranceLevel() and supabase.auth.mfa.listFactors() APIs to identify the AAL level of the session and any factors that are enabled for a user, similar to how you would use these on the browser.
Server-side AAL mismatch handling
Encountering a different AAL level on the server may not be a security problem. Likely scenarios include: user signed-in with a conventional method but closed their tab on the MFA flow, user forgot a tab open for a very long time, or user has lost their authenticator device and is confused about the next steps. Instead of rendering an HTTP 401 Unauthorized or HTTP 403 Forbidden content, redirect users to a page where they can authenticate using their additional factor.
Protecting APIs with MFA
For APIs that use the Supabase Database, Storage or Edge Functions, Row Level Security policies provide sufficient protection. For other APIs, follow these guidelines: (1) Use a good JWT verification and parsing library for your language to securely parse JWTs and extract their claims. (2) Retrieve the 'aal' claim from the JWT and compare its value according to your needs. If an AAL level that can be increased is encountered, ask the user to continue the login process instead of logging them out. (3) Use the 'https://<project-ref>.supabase.co/rest/v1/auth/factors' REST endpoint to identify if the user has enrolled any MFA factors. Only 'verified' factors should be acted upon.
AMR claim example
An example AMR claim structure describing a user that first signed in with a password-based method, then went through TOTP MFA 2 minutes and 12 seconds later:
{
"amr": [
{
"method": "totp",
"timestamp": 1666086056
},
{
"method": "password",
"timestamp": 1666085924
}
]
}
getAuthenticatorAssuranceLevel() method
Use the supabase.auth.mfa.getAuthenticatorAssuranceLevel() method to get easy access to authentication methods reference information in your browser app.
Accessing AMR in RLS policies
You can use this Postgres snippet in RLS policies to access the most recent authentication method in the AMR claim:
jsonb_path_query((select auth.jwt()), '$.amr[0]')
The jsonb_path_query(json, path) function allows access to elements in a JSON object according to a SQL/JSON path. The $.amr[0] path expression fetches the most recent authentication method in the JWT.
Recognized authentication methods in AMR claim
Currently recognized authentication methods in the AMR claim are: 'oauth' (any OAuth based sign in, i.e., social login), 'password' (any password based sign in), 'otp' (any one-time password based sign in including email code, SMS code, magic link), 'totp' (a TOTP additional factor), 'sso/saml' (any Single Sign On SAML method), and 'anonymous' (any anonymous sign in).
Additional AMR methods with PKCE flow
Additional authentication methods are available when using PKCE flow: 'invite' (any sign in via an invitation), 'magiclink' (any sign in via magic link, excluding logins resulting from invocation of signUp), 'email/signup' (any login resulting from an email signup), and 'email_change' (any login resulting from a change in email).
UnenrollMFA React component example
Example React component that shows unenrollment of MFA:
export function UnenrollMFA() {
const [factorId, setFactorId] = useState('')
const [factors, setFactors] = useState([])
const [error, setError] = useState('')
useEffect(() => {
;(async () => {
const { data, error } = await supabase.auth.mfa.listFactors()
if (error) {
throw error
}
setFactors([...data.totp, ...data.phone])
})()
}, [])
return (
<>
{error && <div className="error">{error}</div>}
<tbody>
<tr>
<td>Factor ID</td>
<td>Friendly Name</td>
<td>Factor Status</td>
<td>Phone Number</td>
</tr>
{factors.map((factor) => (
<tr>
<td>{factor.id}</td>
<td>{factor.friendly_name}</td>
<td>{factor.factor_type}</td>
<td>{factor.status}</td>
<td>{factor.phone}</td>
</tr>
))}
</tbody>
<input type="text" value={verifyCode} onChange={(e) => setFactorId(e.target.value.trim())} />
<button onClick={() => supabase.auth.mfa.unenroll({ factorId })}>Unenroll</button>
</>
)
}
The component fetches all existing factors using supabase.auth.mfa.listFactors(), displays them in a table, and allows unenrollment by clicking the Unenroll button after typing the factorId.
listFactors() API response structure
The supabase.auth.mfa.listFactors() API returns an object with 'totp' and 'phone' arrays. Each factor object in these arrays contains: id, friendly_name, factor_type, status, and phone (for phone factors).
MFA definition and purpose
Multi-factor authentication (MFA), sometimes called two-factor authentication (2FA), adds an additional layer of security to an application by verifying user identity through additional verification steps. It is considered a best practice to use MFA for applications.
MFA protects against account takeover
Users with weak passwords or compromised social login accounts are prone to malicious account takeovers. These can be prevented with MFA because MFA requires users to provide proof of both something they know (password or access to a social-login account) and something they have (access to an authenticator app such as TOTP, or a mobile phone).
Supabase Auth MFA methods
Supabase Auth implements MFA via two methods: App Authenticator, which uses Time-based One-Time Password (TOTP), and phone messaging, which uses a code generated by Supabase Auth.
MFA enrollment and authentication flows required
Applications using MFA require two important flows: an enrollment flow that lets users set up and control MFA in the app, and an authentication flow that lets users sign in using any factors after the conventional login step.
Supabase Auth MFA APIs
Supabase Auth provides three MFA-related APIs: Enrollment API for building user interfaces for adding and removing factors, Challenge and Verify APIs for securely verifying that the user has access to a factor, and List Factors API for building user interfaces for signing in with additional factors. Access to the Enrollment API as well as the Challenge and Verify APIs can be controlled via the Supabase Dashboard, where a setting of 'Verification Disabled' will disable both the challenge API and the verification API.
React AppWithMFA component example
```tsx
function AppWithMFA() {
const [readyToShow, setReadyToShow] = useState(false)
const [showMFAScreen, setShowMFAScreen] = useState(false)
useEffect(() => {
;(async () => {
try {
const { data, error } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
if (error) {
throw error
}
console.log(data)
if (data.nextLevel === 'aal2' && data.nextLevel !== data.currentLevel) {
setShowMFAScreen(true)
}
} finally {
setReadyToShow(true)
}
})()
}, [])
if (readyToShow) {
if (showMFAScreen) {
return <AuthMFA />
}
return <App />
}
return <></>
}
```
This example shows how to check AAL after login and conditionally show an MFA screen before displaying the authenticated app.
TOTP enrollment takes three API steps
To enroll a TOTP factor: (1) Call supabase.auth.mfa.enroll() which returns a QR code and secret; display the QR code to the user and ask them to scan it with their authenticator app, or show the secret in plain text if they cannot scan. (2) Call supabase.auth.mfa.challenge() which prepares Supabase Auth to accept a verification code and returns a challenge ID. (3) Call supabase.auth.mfa.verify() which verifies the user has added the secret and if verification succeeds, the factor becomes active for the user account.
TOTP MFA basic flow: enrollment and login
TOTP multi-factor authentication uses a timed one-time password generated from an authenticator app. A QR code transmits a shared secret used to generate the one-time password. Users scan the QR code with their phone to capture the shared secret. The QR code has an alternate URI representation following the otpauth scheme: otpauth://totp/issuer:username?secret=<secret>&issuer=issuer
React AuthMFA component example for challenge
```tsx
function AuthMFA() {
const [verifyCode, setVerifyCode] = useState('')
const [error, setError] = useState('')
const onSubmitClicked = () => {
setError('')
;(async () => {
const factors = await supabase.auth.mfa.listFactors()
if (factors.error) {
throw factors.error
}
const totpFactor = factors.data.totp[0]
if (!totpFactor) {
throw new Error('No TOTP factors found!')
}
const factorId = totpFactor.id
const challenge = await supabase.auth.mfa.challenge({ factorId })
if (challenge.error) {
setError(challenge.error.message)
throw challenge.error
}
const challengeId = challenge.data.id
const verify = await supabase.auth.mfa.verify({
factorId,
challengeId,
code: verifyCode,
})
if (verify.error) {
setError(verify.error.message)
throw verify.error
}
})()
}
return (
<>
<div>Please enter the code from your authenticator app.</div>
{error && <div className="error">{error}</div>}
<input
type="text"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value.trim())}
/>
<input type="button" value="Submit" onClick={onSubmitClicked} />
</>
)
}
```
This example shows the MFA challenge screen: call listFactors() to get available factors, create a challenge for the factor, accept user's code, and call verify().
Recommended places to add MFA enrollment
Most applications add the enrollment flow in two places: (1) Right after login or sign up to let users set up MFA immediately after they log in or create an account (offered as an opt-in step to reduce onboarding friction). (2) From within a settings page to allow users to set up, disable or modify their MFA settings.
React EnrollMFA component example
```tsx
export function EnrollMFA({
onEnrolled,
onCancelled,
}: {
onEnrolled: () => void
onCancelled: () => void
}) {
const [factorId, setFactorId] = useState('')
const [qr, setQR] = useState('')
const [verifyCode, setVerifyCode] = useState('')
const [error, setError] = useState('')
const onEnableClicked = () => {
setError('')
;(async () => {
const challenge = await supabase.auth.mfa.challenge({ factorId })
if (challenge.error) {
setError(challenge.error.message)
throw challenge.error
}
const challengeId = challenge.data.id
const verify = await supabase.auth.mfa.verify({
factorId,
challengeId,
code: verifyCode,
})
if (verify.error) {
setError(verify.error.message)
throw verify.error
}
onEnrolled()
})()
}
useEffect(() => {
;(async () => {
const { data, error } = await supabase.auth.mfa.enroll({
factorType: 'totp',
})
if (error) {
throw error
}
setFactorId(data.id)
setQR(data.totp.qr_code)
})()
}, [])
return (
<>
{error && <div className="error">{error}</div>}
<img src={qr} />
<input
type="text"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value.trim())}
/>
<input type="button" value="Enable" onClick={onEnableClicked} />
<input type="button" value="Cancel" onClick={onCancelled} />
</>
)
}
```
This example shows how to implement MFA enrollment: call enroll() on mount to get QR code, display it to the user, accept their verification code, then call challenge() and verify() when they click Enable.
Authenticator Assurance Level (AAL) table
The combined meaning of currentLevel and nextLevel:
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)
TOTP code validity period
In Supabase's TOTP implementation, each generated code remains valid for one interval spanning 30 seconds. To account for minor time discrepancies, a one-interval clock skew is allowed, ensuring users can successfully authenticate within this timeframe even with slight variations in system clocks.
TOTP MFA API availability
The TOTP MFA API is free to use and is enabled on all Supabase projects by default.
List MFA factors with listFactors API
Use supabase.auth.mfa.listFactors() to extract the available MFA factors for the user. This method is very quick and rarely uses the network. If it returns more than one factor or of different types, present the user with a choice of which factor to use.
QR code otpauth URI format
The QR code uses the otpauth scheme URI format. Example: otpauth://totp/supabase:alice@supabase.com?secret=<secret>&issuer=supabase. This format can be manually input in cases where there is difficulty rendering a QR code.
Google Authenticator introduced QR code standard for TOTP
The use of a QR code for TOTP was initially introduced by Google Authenticator but is now universally accepted by all authenticator apps.
Check AAL after login with getAuthenticatorAssuranceLevel
After a user signs in and is redirected back to your app, call supabase.auth.mfa.getAuthenticatorAssuranceLevel() to extract the user's current and next authenticator assurance level. If currentLevel is aal1 but nextLevel is aal2, the user should be given the option to go through MFA. This method is very fast (microseconds) and rarely uses the network.
TOTP setup flow vs login flow
In the setup flow, a session at AAL1 calls the Enroll API which returns a QR code. The user enters the generated code, Challenge and Verify APIs check it, and on success the session upgrades to AAL2. In the login flow, the user signs in (upgrading to AAL1) and List Factors API is called. If the user has factors enrolled, they open their authenticator and enter a code following the same Challenge and Verify path to reach AAL2. If they have no factors enrolled, they are sent through the setup flow.
MFA with SAML SSO affects amr array
If Multi-Factor Authentication is used with SAML SSO, the amr array may have a different method at index 0, so care should be taken when using amr expressions in Row Level Security policies.
MFA challenge and verification rate limits
The endpoints `/auth/v1/factors/:id/challenge` and `/auth/v1/factors/:id/verify` for creating or verifying MFA challenges are rate limited by IP Address. The limit is auth.rate_limits.mfa.requests_per_hour requests per hour with bursts up to auth.rate_limits.verification.mfa requests. This limit is not customizable.
MFA protection required for phone-based password auth
Users who use a phone number as a password-based auth identifier must be protected by enabling multi-factor authentication (MFA) due to the risk of phone number recycling.