diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index a8fb38e78bc..100afb30208 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -569,7 +569,14 @@ export const auth = { name: 'Identity Linking', url: '/guides/auth/auth-identity-linking', }, - { name: 'Multi-Factor Authentication', url: '/guides/auth/auth-mfa' }, + { + name: 'Multi-Factor Authentication', + url: '/guides/auth/auth-mfa', + items: [ + { name: 'App Authenticator (TOTP)', url: '/guides/auth/auth-mfa/totp' }, + { name: 'Phone', url: '/guides/auth/auth-mfa/phone' }, + ], + }, { name: 'Signout', url: '/guides/auth/signout', diff --git a/apps/docs/content/guides/auth/auth-mfa.mdx b/apps/docs/content/guides/auth/auth-mfa.mdx index ca678317549..e2146375370 100644 --- a/apps/docs/content/guides/auth/auth-mfa.mdx +++ b/apps/docs/content/guides/auth/auth-mfa.mdx @@ -13,11 +13,11 @@ Users with weak passwords or compromised social login accounts are prone to mali - Something they know. Password, or access to a social-login account. - Something they have. - Access to an authenticator app (a.k.a. TOTP), mobile phone or recovery code. + Access to an authenticator app (a.k.a. TOTP) or a mobile phone. ## Overview -Supabase Auth implements only Time-based One Time Password(TOTP) multi-factor authentication. This type of multi-factor authentication uses a timed one-time password generated from an authenticator app in the control of users. The [MFA API](/docs/reference/javascript/auth-mfa-api) is enabled on all Supabase projects by default. +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: @@ -32,16 +32,7 @@ Supabase Auth provides: - **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. -Below is a flow chart illustrating how these APIs work together to enable MFA features in your app. - -Diagram showing the flow of Multi-Factor authentication +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. @@ -55,7 +46,7 @@ This information is represented by an [Authenticator Assurance Level](https://pa login. 2. **Assurance Level 2: `aal2`** Means that the user's identity was additionally verified using at least one - second factor, such as a TOTP code. + 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. @@ -67,7 +58,7 @@ Adding MFA to your app involves these four steps: 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. -2. **Add unenrollment flow.** +2. **Add unenroll flow.** You need to support a UI through which users can see existing devices and unenroll devices which are no longer relevant. 3. **Add challenge step to login.** @@ -79,140 +70,39 @@ Adding MFA to your app involves these four steps: authorization rules across your app: on the frontend, backend, API servers or Row-Level Security policies. -### Add enrollment flow +The enrollment flow and the challenge steps differ by factor and are covered on a separate page. Visit the [Phone](/docs/guides/auth/auth-mfa/phone) or [App Authenticator](/docs/guides/auth/auth-mfa/totp) 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. -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: +### Add unenroll flow -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. +The unenroll process is the same for both Phone and TOTP factors. -We recommend building one generic flow that you can reuse in both cases with minor modifications. +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. -Enrolling a factor for use with MFA takes three steps: +When a user unenrolls a factor, call `supabase.auth.mfa.unenroll()` with the ID of the factor. For example, call: -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. -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 `` 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. - -```tsx -/** - * EnrollMFA shows a simple enrollment dialog. When shown on screen it calls - * the `enroll` API. Each time a user clicks the Enable button it calls the - * `challenge` and `verify` APIs to check if the code provided by the user is - * valid. - * When enrollment is successful, it calls `onEnrolled`. When the user clicks - * Cancel the `onCancelled` callback is called. - */ -export function EnrollMFA({ - onEnrolled, - onCancelled, -}: { - onEnrolled: () => void - onCancelled: () => void -}) { - const [factorId, setFactorId] = useState('') - const [qr, setQR] = useState('') // holds the QR code image SVG - const [verifyCode, setVerifyCode] = useState('') // contains the code entered by the user - const [error, setError] = useState('') // holds an error message - - 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) - - // Supabase Auth returns an SVG QR code which you can convert into a data - // URL that you can place in an tag. - setQR(data.totp.qr_code) - })() - }, []) - - return ( - <> - {error &&
{error}
} - - setVerifyCode(e.target.value.trim())} - /> - - - - ) -} +``` +supabase.auth.mfa.unenroll({factorId: "d30fd651-184e-4748-a928-0a4b9be1d429"}) ``` -### Add unenrollment flow +to unenroll a factor with ID `d30fd651-184e-4748-a928-0a4b9be1d429`. -An unenrollment 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. +### Enforce rules for MFA logins -When a user unenrolls a factor, call `supabase.auth.mfa.unenroll()` with the ID of the factor. For example, call `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. + +1. **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. +2. **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. +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. #### Example: React @@ -248,7 +138,7 @@ export function UnenrollMFA() { throw error } - setFactors(data.totp) + setFactors([...data.totp, ...data.phone]) })() }, []) @@ -260,6 +150,7 @@ export function UnenrollMFA() { Factor ID Friendly Name Factor Status + Phone Number {factors.map((factor) => ( @@ -267,6 +158,7 @@ export function UnenrollMFA() { {factor.friendly_name} {factor.factor_type} {factor.status} + {factor.phone} ))} @@ -277,164 +169,6 @@ export function UnenrollMFA() { } ``` -### Add 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 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.) | - -#### 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. - -```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 - } - - return - } - - return <> -} -``` - -- `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. - -```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 ( - <> -
Please enter the code from your authenticator app.
- {error &&
{error}
} - setVerifyCode(e.target.value.trim())} - /> - - - ) -} -``` - -- 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. - -### 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. - -1. **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. -2. **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. -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. - #### Database Your app should sufficiently deny or allow access to tables or rows based on the user's current and possible authenticator levels. @@ -568,33 +302,6 @@ If your application uses the Supabase Database, Storage or Edge Functions, just className="text-foreground-light mt-8 mb-6 [&>div]:space-y-4" > -Why is there a challenge and verify API when challenge does not do much?} -id="why-is-there-a-challenge-and-verify-api" -> - -TOTP is not going to be the only MFA factor Supabase Auth is going to support in the future. By separating out the challenge and verify steps, we're making the library forward compatible with new factors we may add in the future -- such as SMS or WebAuthn. For example, for SMS the `challenge` endpoint would actually send out the SMS with the authentication code. For convenience, you may use `challengeAndVerify` to create and verify a challenge in a single step. - - - -What's inside the QR code?} -id="what-is-inside-the-qr-code" -> - -The TOTP QR code encodes a URI with the `otpauth` scheme. It was [initially introduced by Google Authenticator](https://github.com/google/google-authenticator/wiki/Key-Uri-Format) but is now universally accepted by all authenticator apps. - - - -How long is the TOTP code valid for?} -id="how-long-is-the-totp-code-valid-for" -> - -In our TOTP implementation, each generated code remains valid for one interval, which spans 30 seconds. To account for minor time discrepancies, we allow for a one-interval clock skew. This ensures that users can successfully authenticate within this timeframe, even if there are slight variations in system clocks. - - - How do I check when a user went through MFA?} id="how-do-i-check-when-a-user-went-through-mfa" diff --git a/apps/docs/content/guides/auth/auth-mfa/phone.mdx b/apps/docs/content/guides/auth/auth-mfa/phone.mdx new file mode 100644 index 00000000000..b4e39019e59 --- /dev/null +++ b/apps/docs/content/guides/auth/auth-mfa/phone.mdx @@ -0,0 +1,313 @@ +--- +id: 'auth-mfa-phone' +title: 'Multi-Factor Authentication (Phone)' +description: 'Add an additional layer of security with phone (SMS or WhatsApp) multi-factor-authentication.' +--- + +## How does phone multi-factor-authentication? + +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](/docs/guides/auth/phone-login). The same provider configuration that is used for phone login is used for MFA. You can also use the [Send SMS Hook](/docs/guides/auth/auth-hooks/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). + +Diagram showing the flow of Multi-Factor authentication + +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. + +```tsx +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 &&
{error}
} + setPhoneNumber(e.target.value.trim())} + /> + setVerifyCode(e.target.value.trim())} + /> + + + + + + ) +} +``` + +### 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 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.) | + +#### 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. + +```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 + } + + return + } + + return <> +} +``` + +- `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. + +```tsx +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 ( + <> +
Please enter the code sent to your phone.
+ {phoneNumber &&
Phone number: {phoneNumber}
} + {error &&
{error}
} + setVerifyCode(e.target.value.trim())} + /> + {!challengeId ? ( + + ) : ( + + )} + + ) +} +``` + +- 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](/dashboard/project/_/settings/auth). + +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](https://en.wikipedia.org/wiki/SIM_swap_scam) diff --git a/apps/docs/content/guides/auth/auth-mfa/totp.mdx b/apps/docs/content/guides/auth/auth-mfa/totp.mdx new file mode 100644 index 00000000000..cc6eed42b88 --- /dev/null +++ b/apps/docs/content/guides/auth/auth-mfa/totp.mdx @@ -0,0 +1,320 @@ +--- +id: 'auth-mfa-totp' +title: 'Multi-Factor Authentication (TOTP)' +description: 'Add an additional layer of security to your apps with TOTP multi-factor authentication.' +--- + +## 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](https://github.com/google/google-authenticator/wiki/Key-Uri-Format) 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=&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). + +Diagram showing the flow of Multi-Factor authentication + +[TOTP MFA API](/docs/reference/javascript/auth-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 `` 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. + +```tsx +/** + * EnrollMFA shows a simple enrollment dialog. When shown on screen it calls + * the `enroll` API. Each time a user clicks the Enable button it calls the + * `challenge` and `verify` APIs to check if the code provided by the user is + * valid. + * When enrollment is successful, it calls `onEnrolled`. When the user clicks + * Cancel the `onCancelled` callback is called. + */ +export function EnrollMFA({ + onEnrolled, + onCancelled, +}: { + onEnrolled: () => void + onCancelled: () => void +}) { + const [factorId, setFactorId] = useState('') + const [qr, setQR] = useState('') // holds the QR code image SVG + const [verifyCode, setVerifyCode] = useState('') // contains the code entered by the user + const [error, setError] = useState('') // holds an error message + + 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) + + // Supabase Auth returns an SVG QR code which you can convert into a data + // URL that you can place in an tag. + setQR(data.totp.qr_code) + })() + }, []) + + return ( + <> + {error &&
{error}
} + + setVerifyCode(e.target.value.trim())} + /> + + + + ) +} +``` + +### 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 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.) | + +#### 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. + +```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 + } + + return + } + + return <> +} +``` + +- `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. + +```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 ( + <> +
Please enter the code from your authenticator app.
+ {error &&
{error}
} + setVerifyCode(e.target.value.trim())} + /> + + + ) +} +``` + +- 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 + + + +What's inside the QR code?} +id="what-is-inside-the-qr-code" +> + + + +How long is the TOTP code valid for?} +id="how-long-is-the-totp-code-valid-for" +> + +In our TOTP implementation, each generated code remains valid for one interval, which spans 30 seconds. To account for minor time discrepancies, we allow for a one-interval clock skew. This ensures that users can successfully authenticate within this timeframe, even if there are slight variations in system clocks. + + + + diff --git a/apps/docs/public/img/guides/auth-mfa/auth-mfa-phone-flow.svg b/apps/docs/public/img/guides/auth-mfa/auth-mfa-phone-flow.svg new file mode 100644 index 00000000000..976dd3525c4 --- /dev/null +++ b/apps/docs/public/img/guides/auth-mfa/auth-mfa-phone-flow.svg @@ -0,0 +1,3 @@ + + +
Yes
No
1 or more factors
0 factors
Setup flow
Session is AAL1
Enroll API
Challenge API
Code sent to User
User: Enter code
Verify API
Is code correct?
Upgrade to AAL2
Done
Login flow
User: Sign-in
Upgrade to AAL1
List Factors API
User: Select phone factor
Setup flow
\ No newline at end of file diff --git a/apps/docs/public/img/guides/auth-mfa/auth-mfa-phone.mermaid b/apps/docs/public/img/guides/auth-mfa/auth-mfa-phone.mermaid new file mode 100644 index 00000000000..92c9f199964 --- /dev/null +++ b/apps/docs/public/img/guides/auth-mfa/auth-mfa-phone.mermaid @@ -0,0 +1,7 @@ +graph TD; + InitS((Setup flow)) --> SAAL1[/Session is AAL1/] --> Enroll[Enroll API] --> ChallengeAPI[Challenge API] --> Scan[/Code sent to User/] --> Enter[User: Enter code] --> Verify[Verify API] --> Check{{Is code correct?}} + Check -->|Yes| AAL2[/Upgrade to AAL2/] --> Done((Done)) + Check -->|No| Enter + InitA((Login flow)) --> SignIn([User: Sign-in]) --> AAL1[/Upgrade to AAL1/] --> ListFactors[List Factors API] + ListFactors -->|1 or more factors| OpenAuth([User: Select phone factor]) --> Enter + ListFactors -->|0 factors| Setup[[Setup flow]]