apps/docs/content/guides/auth/auth-mfa.mdx
Multi-factor authentication (MFA), sometimes called two-factor authentication (2FA), adds an additional layer of security to your application by verifying their identity through additional verification steps.
It is considered a best practice to use MFA for your applications.
Users with weak passwords or compromised social login accounts are prone to malicious account takeovers. These can be prevented with MFA because they require the user to provide proof of both of these:
Supabase Auth implements MFA via two methods: App Authenticator, which makes use of a Time based-one Time Password, and phone messaging, which makes use of a code generated by Supabase Auth.
Applications using MFA require two important flows:
Supabase Auth provides:
You can control access to the Enrollment API as well as the Challenge and Verify APIs via the Supabase Dashboard. A setting of Verification Disabled will disable both the challenge API and the verification API.
These sets of APIs let you control the MFA experience that works for you. You can create flows where MFA is optional, mandatory for all, or only specific groups of users.
Once users have enrolled or signed-in with a factor, Supabase Auth adds additional metadata to the user's access token (JWT) that your application can use to allow or deny access.
This information is represented by an Authenticator Assurance Level, a standard measure about the assurance of the user's identity Supabase Auth has for that particular session. There are two levels recognized today:
aal1
Means that 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 that the user's identity was additionally verified using at least one
second factor, such as a TOTP code or One-Time Password code.This assurance level is encoded in the aal claim in the JWT associated with the user. By decoding this value you can create custom authorization rules in your frontend, backend, and database that will enforce the MFA policy that works for your application. JWTs without an aal claim are at the aal1 level.
Adding MFA to your app involves these four steps:
The enrollment flow and the challenge steps differ by factor and are covered on a separate page. Visit the Phone or App Authenticator pages to see how to add the flows for the respective factors. You can combine both flows and allow for use of both Phone and App Authenticator Factors.
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. Most applications do so via a factor management page where users can view and unlink selected factors.
When a user unenrolls a factor, call supabase.auth.mfa.unenroll() with the ID of the factor. For example, call:
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
'https://your-project-id.supabase.co',
'sb_publishable_... or anon key'
)
// ---cut---
supabase.auth.mfa.unenroll({ factorId: 'd30fd651-184e-4748-a928-0a4b9be1d429' })
to unenroll a factor with ID d30fd651-184e-4748-a928-0a4b9be1d429.
Adding MFA to your app's UI does not in-and-of-itself offer a higher level of security to your users. You also need to enforce the MFA rules in your application's database, APIs, and server-side rendering.
Depending on your application's needs, there are three ways you can choose to enforce MFA.
Below is an example that creates a new UnenrollMFA component that illustrates the important pieces of the MFA enrollment flow. Note that users can only unenroll a factor after completing the enrollment flow and obtaining an aal2 JWT claim. Here are some points of note:
supabase.auth.mfa.listFactors() endpoint
fetches all existing factors together with their details.factorId and click Unenroll
which creates a confirmation modal.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 enrolling one will need to manually call refreshSession()
/**
* UnenrollMFA shows a simple table with the list of factors together with a button to unenroll.
* When a user types in the factorId of the factor that they wish to unenroll and clicks unenroll
* the corresponding factor will be unenrolled.
*/
export function UnenrollMFA() {
const [factorId, setFactorId] = useState('')
const [factors, setFactors] = useState([])
const [error, setError] = useState('') // holds an error message
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>
</>
)
}
Your app should sufficiently deny or allow access to tables or rows based on the user's current and possible authenticator levels.
<Admonition type="caution">Postgres has two types of policies: permissive and restrictive. This guide uses restrictive policies. Make sure you don't omit the as restrictive clause.
If your app falls under this case, this is a template Row Level Security policy you can apply to all your tables:
create policy "Policy name."
on table_name
as restrictive
to authenticated
using ((select auth.jwt()->>'aal') = 'aal2');
aal claim other than
aal2, which is the highest authenticator assurance level.as restrictive ensures this policy will restrict all commands on the
table regardless of other policies!If your app falls under this case, the rules get more complex. User accounts created past a certain timestamp must have a aal2 level to access the database.
create policy "Policy name."
on table_name
as restrictive -- very important!
to authenticated
using
(array[(select auth.jwt()->>'aal')] <@ (
select
case
when created_at >= '2022-12-12T00:00:00Z' then array['aal2']
else array['aal1', 'aal2']
end as aal
from auth.users
where (select auth.uid()) = id));
aal1 and aal2 for users with a created_at
timestamp prior to 12th December 2022 at 00:00 UTC, but will only accept
aal2 for all other timestamps.<@ operator is PostgreSQL's "contained in"
operator.as restrictive ensures this policy will restrict all commands on the
table regardless of other policies!Users that have enrolled MFA on their account are expecting that your application only works for them if they've gone through MFA.
create policy "Policy name."
on table_name
as restrictive -- very important!
to authenticated
using (
array[(select auth.jwt()->>'aal')] <@ (
select
case
when count(id) > 0 then array['aal2']
else array['aal1', 'aal2']
end as aal
from auth.mfa_factors
where ((select auth.uid()) = user_id) and status = 'verified'
));
aal2 when the user has at least one MFA
factor verified.aal1 and aal2.<@ operator is PostgreSQL's "contained in"
operator.as restrictive ensures this policy will restrict all commands on the
table regardless of other policies!When using the Supabase JavaScript library in a server-side rendering context, make sure you always create a new object for each request! This will prevent you from accidentally rendering and serving content belonging to different users.
</Admonition>It is possible to enforce MFA on the Server-Side Rendering level. However, this can be tricky do to well.
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.
However, encountering a different AAL level on the server may not actually be a security problem. Consider these likely scenarios:
We thus recommend you redirect users to a page where they can authenticate using their additional factor, instead of rendering an HTTP 401 Unauthorized or HTTP 403 Forbidden content.
If your application uses the Supabase Database, Storage or Edge Functions, just using Row Level Security policies will give you sufficient protection. In the event that you have other APIs that you wish to protect, follow these general guidelines:
aal claim from the JWT and compare its value according to
your needs.
If you've encountered an AAL level that can be increased, ask the user to
continue the login process instead of logging them out.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.<Accordion type="default" openBehaviour="multiple" chevronAlign="right" justified size="medium" className="text-foreground-light mt-8 mb-6 [&>div]:space-y-4"
<AccordionItem header={<span className="text-foreground">How do I check when a user went through MFA?</span>} id="how-do-i-check-when-a-user-went-through-mfa"
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.
For example, the following structure describes a user that first signed in with a password-based method, and then went through TOTP MFA 2 minutes and 12 seconds later. The entries are ordered most recent method first!
{
"amr": [
{
"method": "totp",
"timestamp": 1666086056
},
{
"method": "password",
"timestamp": 1666085924
}
]
}
Use the supabase.auth.mfa.getAuthenticatorAssuranceLevel() method to get easy access to this information in your browser app.
You can use this Postgres snippet in RLS policies, too:
jsonb_path_query((select auth.jwt()), '$.amr[0]')
jsonb_path_query(json, path)
is a function that allows access to elements in a JSON object according to a
SQL/JSON
path.$.amr[0] is a SQL/JSON path expression that fetches the most recent
authentication method in the JWT.Once you have extracted the most recent entry in the array, you can compare the method and timestamp to enforce stricter rules. For instance, you can mandate that access will be only be granted on a table to users who have recently signed in with a password.
Currently recognized authentication methods are:
oauth - any OAuth based sign in (social login).password - any password based sign in.otp - any one-time password based sign in (email code, SMS code, magic
link).totp - a TOTP additional factor.sso/saml - any Single Sign On (SAML) method.anonymous - any anonymous sign in.The following additional claims are available when using PKCE flow:
invite - any sign in via an invitation.magiclink - any sign in via magic link. Excludes logins resulting from invocation of signUp.email/signup - any login resulting from an email signup.email_change - any login resulting from a change in email.More authentication methods will be added over time as we increase the number of authentication methods supported by Supabase.
</AccordionItem> </Accordion>