Multi-Factor Authentication
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:
- Something they know. Password, or access to a social-login account.
- Something they have. Access to an authenticator app (a.k.a. TOTP) or a mobile phone.
Overview
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:
- Enrollment flow. This lets users set up and control MFA in your app.
- Authentication flow. This lets users sign in using any factors after the conventional login step.
Supabase Auth provides:
- Enrollment API - build rich user interfaces for adding and removing factors.
- Challenge and Verify APIs - securely verify that the user has access to a factor.
- List Factors API - build rich user interfaces for signing in with additional factors.
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:
- Assurance Level 1:
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. - Assurance Level 2:
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 to your app
Adding MFA to your app involves these four steps:
- Add enrollment flow. You need to provide a UI within your app that your users will be able to set-up MFA in. You can add this right after sign-up, or as part of a separate flow in the settings portion of your app.
- Add unenroll flow. You need to support a UI through which users can see existing devices and unenroll devices which are no longer relevant.
- Add challenge step to login. If a user has set-up MFA, your app's login flow needs to present a challenge screen to the user asking them to prove they have access to the additional factor.
- Enforce rules for MFA logins. Once your users have a way to enroll and log in with MFA, you need to enforce authorization rules across your app: on the frontend, backend, API servers or Row-Level Security policies.
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.
Add unenroll flow
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:
_10supabase.auth.mfa.unenroll({factorId: "d30fd651-184e-4748-a928-0a4b9be1d429"})
to unenroll a factor with ID d30fd651-184e-4748-a928-0a4b9be1d429
.
Enforce rules for MFA logins
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.
- Enforce for all users (new and existing). Any user account will have to enroll MFA to continue using your app. The application will not allow access without going through MFA first.
- Enforce for new users only. Only new users will be forced to enroll MFA, while old users will be encouraged to do so. The application will not allow access for new users without going through MFA first.
- 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.
Example: React
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:
- When the component appears on screen, the
supabase.auth.mfa.listFactors()
endpoint fetches all existing factors together with their details. - The existing factors for a user are displayed in a table.
- Once the user has selected a factor to unenroll, they can type in the 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()
_46/**_46 * UnenrollMFA shows a simple table with the list of factors together with a button to unenroll._46 * When a user types in the factorId of the factor that they wish to unenroll and clicks unenroll_46 * the corresponding factor will be unenrolled._46 */_46export function UnenrollMFA() {_46 const [factorId, setFactorId] = useState('')_46 const [factors, setFactors] = useState([])_46 const [error, setError] = useState('') // holds an error message_46_46 useEffect(() => {_46 ;(async () => {_46 const { data, error } = await supabase.auth.mfa.listFactors()_46 if (error) {_46 throw error_46 }_46_46 setFactors([...data.totp, ...data.phone])_46 })()_46 }, [])_46_46 return (_46 <>_46 {error && <div className="error">{error}</div>}_46 <tbody>_46 <tr>_46 <td>Factor ID</td>_46 <td>Friendly Name</td>_46 <td>Factor Status</td>_46 <td>Phone Number</td>_46 </tr>_46 {factors.map((factor) => (_46 <tr>_46 <td>{factor.id}</td>_46 <td>{factor.friendly_name}</td>_46 <td>{factor.factor_type}</td>_46 <td>{factor.status}</td>_46 <td>{factor.phone}</td>_46 </tr>_46 ))}_46 </tbody>_46 <input type="text" value={verifyCode} onChange={(e) => setFactorId(e.target.value.trim())} />_46 <button onClick={() => supabase.auth.mfa.unenroll({ factorId })}>Unenroll</button>_46 </>_46 )_46}
Database
Your app should sufficiently deny or allow access to tables or rows based on the user's current and possible authenticator levels.
PostgreSQL has two types of policies: permissive and restrictive. This guide uses restrictive policies. Make sure you don't omit the as restrictive
clause.
Enforce for all users (new and existing)
If your app falls under this case, this is a template Row Level Security policy you can apply to all your tables:
_10create policy "Policy name."_10 on table_name_10 as restrictive_10 to authenticated_10 using ((select auth.jwt()->>'aal') = 'aal2');
- Here the policy will not accept any JWTs with an
aal
claim other thanaal2
, which is the highest authenticator assurance level. - Using
as restrictive
ensures this policy will restrict all commands on the table regardless of other policies!
Enforce for new users only
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.
_13create policy "Policy name."_13 on table_name_13 as restrictive -- very important!_13 to authenticated_13 using_13 (array[(select auth.jwt()->>'aal')] <@ (_13 select_13 case_13 when created_at >= '2022-12-12T00:00:00Z' then array['aal2']_13 else array['aal1', 'aal2']_13 end as aal_13 from auth.users_13 where (select auth.uid()) = id));
- The policy will accept both
aal1
andaal2
for users with acreated_at
timestamp prior to 12th December 2022 at 00:00 UTC, but will only acceptaal2
for all other timestamps. - The
<@
operator is PostgreSQL's "contained in" operator. - Using
as restrictive
ensures this policy will restrict all commands on the table regardless of other policies!
Enforce only for users that have opted-in
Users that have enrolled MFA on their account are expecting that your application only works for them if they've gone through MFA.
_14create policy "Policy name."_14 on table_name_14 as restrictive -- very important!_14 to authenticated_14 using (_14 array[(select auth.jwt()->>'aal')] <@ (_14 select_14 case_14 when count(id) > 0 then array['aal2']_14 else array['aal1', 'aal2']_14 end as aal_14 from auth.mfa_factors_14 where ((select auth.uid()) = user_id) and status = 'verified'_14 ));
- The policy will only accept only
aal2
when the user has at least one MFA factor verified. - Otherwise, it will accept both
aal1
andaal2
. - The
<@
operator is PostgreSQL's "contained in" operator. - Using
as restrictive
ensures this policy will restrict all commands on the table regardless of other policies!
Server-Side Rendering
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.
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:
- 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. (This happens more often than you might imagine.)
- User has lost their authenticator device and is confused about the next steps.
We thus recommend you redirect users to a page where they can authenticate using their additional factor, instead of rendering a HTTP 401 Unauthorized or HTTP 403 Forbidden content.
APIs
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:
- Use a good JWT verification and parsing library for your language. This will let you securely parse JWTs and extract their claims.
- Retrieve the
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. - Use the
https://<project-ref>.supabase.co/rest/v1/auth/factors
REST endpoint to identify if the user has enrolled any MFA factors. Onlyverified
factors should be acted upon.