Email Magic Link with Multi-Factor Authentication
This guide shows you how to implement authentication with Email Magic Link and TOTP-based multi-factor authentication in your React application.
Overview
When a user has MFA enabled with an authenticator app (TOTP), the login flow requires the following steps:
- The user enters their email address to request a magic link
- If MFA is enabled for their account, they’re prompted to enter the 6-digit TOTP code from their authenticator app (e.g., Google Authenticator)
- After entering a valid TOTP code, a magic link is sent to their email
- The user clicks the magic link from email to complete authentication
- Upon successful verification, the user is authenticated and redirected to the appropriate page
This two-factor approach provides an additional layer of security beyond a standard magic link.
Implementation
Step 1: Initialize Authentication and Handle MFA Required Error
First, attempt to authenticate with email. If MFA is required, an error will be thrown. You can handle this error by prompting the user to enter their TOTP code.
import React from "react";
import { function useAuthenticate(mutationArgs?: UseAuthenticateMutationArgs): UseAuthenticateResultHook that provides functions and state for authenticating a user using a signer. It includes methods for both synchronous and asynchronous mutations. Useful if building your own UI components and want to control the authentication flow. For authenticate vs authenticateAsync, use authenticate when you want the hook the handle state changes for you, authenticateAsync when you need to wait for the result to finish processing.
This can be complex for magic link or OTP flows: OPT calls authenticate twice, but this should be handled by the signer.
useAuthenticate } from "@account-kit/react";
import { class MfaRequiredErrorMfaRequiredError } from "@account-kit/signer";
import { function useState<S>(initialState: S | (() => S)): [S, React.Dispatch<React.SetStateAction<S>>] (+1 overload)Returns a stateful value, and a function to update it.
useState } from "react";
function function MagicLinkWithMFA(): JSX.ElementMagicLinkWithMFA() {
const { const authenticate: UseMutateFunction<User, Error, AuthParams, unknown>authenticate } = function useAuthenticate(mutationArgs?: UseAuthenticateMutationArgs): UseAuthenticateResultHook that provides functions and state for authenticating a user using a signer. It includes methods for both synchronous and asynchronous mutations. Useful if building your own UI components and want to control the authentication flow. For authenticate vs authenticateAsync, use authenticate when you want the hook the handle state changes for you, authenticateAsync when you need to wait for the result to finish processing.
This can be complex for magic link or OTP flows: OPT calls authenticate twice, but this should be handled by the signer.
useAuthenticate();
// Step 1: Handle initial email submission and check for MFA requirement
const const handleInitialAuthentication: (email: string) => voidhandleInitialAuthentication = (email: stringemail: string) => {
const authenticate: (variables: AuthParams, options?: MutateOptions<User, Error, AuthParams, unknown> | undefined) => voidauthenticate(
{
type: "email"type: "email",
emailMode?: "otp" | "magicLink" | undefinedemailMode: "magicLink",
email: stringemail,
},
{
MutateOptions<User, Error, AuthParams, unknown>.onSuccess?: ((data: User, variables: AuthParams, context: unknown) => void) | undefinedonSuccess: () => {
// This callback only fires when the entire auth flow is complete
// (user clicked magic link and completed MFA if required)
var console: ConsoleThe console
module provides a simple debugging console that is similar to the JavaScript console mechanism provided by web browsers.
The module exports two specific components:
- A
Console
class with methods such as console.log()
, console.error()
and console.warn()
that can be used to write to any Node.js stream. * A global console
instance configured to write to process.stdout
and process.stderr
. The global console
can be used without importing the node:console
module.
Warning: The global console object's methods are neither consistently synchronous like the browser APIs they resemble, nor are they consistently asynchronous like all other Node.js streams. See the note on process I/O
for more information.
Example using the global console
:
const name = 'Will Robinson'; console.warn(`Danger $name! Danger!`); // Prints: Danger Will Robinson! Danger!, to stderr ```
Example using the `Console` class:
```js const out = getStreamSomehow(); const err = getStreamSomehow(); const myConsole = new console.Console(out, err);
myConsole.log('hello world'); // Prints: hello world, to out myConsole.log('hello %s', 'world'); // Prints: hello world, to out myConsole.error(new Error('Whoops, something bad happened')); // Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson'; myConsole.warn(`Danger $name! Danger!`); // Prints: Danger Will Robinson! Danger!, to err ```
console.Console.log(message?: any, ...optionalParams: any[]): void (+1 overload)Prints to stdout
with newline. Multiple arguments can be passed, with the first used as the primary message and all additional used as substitution values similar to printf(3)
(the arguments are all passed to util.format()
).
js const count = 5; console.log('count: %d', count); // Prints: count: 5, to stdout console.log('count:', count); // Prints: count: 5, to stdout
See util.format()
for more information.
log("Authentication successful!");
},
MutateOptions<User, Error, AuthParams, unknown>.onError?: ((error: Error, variables: AuthParams, context: unknown) => void) | undefinedonError: (error: Errorerror) => {
// If MFA is required the attempt will result in an MfaRequiredError
if (error: Errorerror instanceof class MfaRequiredErrorMfaRequiredError) {
const { const multiFactorId: stringmultiFactorId } = error: MfaRequiredErrorerror.MfaRequiredError.multiFactors: MfaFactor[]multiFactors[0];
// Store the multiFactorId to use when the user enters their TOTP code
// In step 2, we will prompt the user to enter their TOTP code (from their authenticator app)
// and we'll use this multiFactorId to verify the TOTP code
}
// Handle other errors
},
},
);
};
return <React.JSX.IntrinsicElements.div: React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>div>{/* Your UI components here */}</React.JSX.IntrinsicElements.div: React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>div>;
}
Step 2: Submit TOTP Code and Complete Magic Link Authentication
Once we have the MFA data from the first step, we can complete the authentication by submitting the TOTP code with the multiFactorId. You must prompt the user to enter their TOTP code (from their authenticator app) and then submit it with the multiFactorId.
import React from "react";
import { function useAuthenticate(mutationArgs?: UseAuthenticateMutationArgs): UseAuthenticateResultHook that provides functions and state for authenticating a user using a signer. It includes methods for both synchronous and asynchronous mutations. Useful if building your own UI components and want to control the authentication flow. For authenticate vs authenticateAsync, use authenticate when you want the hook the handle state changes for you, authenticateAsync when you need to wait for the result to finish processing.
This can be complex for magic link or OTP flows: OPT calls authenticate twice, but this should be handled by the signer.
useAuthenticate } from "@account-kit/react";
// Continuing from the previous component...
function function MagicLinkWithMFA(): JSX.ElementMagicLinkWithMFA() {
const { const authenticate: UseMutateFunction<User, Error, AuthParams, unknown>authenticate } = function useAuthenticate(mutationArgs?: UseAuthenticateMutationArgs): UseAuthenticateResultHook that provides functions and state for authenticating a user using a signer. It includes methods for both synchronous and asynchronous mutations. Useful if building your own UI components and want to control the authentication flow. For authenticate vs authenticateAsync, use authenticate when you want the hook the handle state changes for you, authenticateAsync when you need to wait for the result to finish processing.
This can be complex for magic link or OTP flows: OPT calls authenticate twice, but this should be handled by the signer.
useAuthenticate();
// Prompt the user to enter their TOTP code (from their authenticator app)
// Hardcoded for now, but in a real app you'd get this from the user
const const totpCode: "123456"totpCode = "123456";
const const multiFactorId: "123456"multiFactorId = "123456"; // This is the multiFactorId from the first step
// Step 2: Submit the TOTP code with multiFactorId to complete the flow
const const handleMfaSubmission: (email: string) => voidhandleMfaSubmission = (email: stringemail: string) => {
const authenticate: (variables: AuthParams, options?: MutateOptions<User, Error, AuthParams, unknown> | undefined) => voidauthenticate(
{
type: "email"type: "email",
emailMode?: "otp" | "magicLink" | undefinedemailMode: "magicLink",
email: stringemail,
// The multiFactors array tells the authentication system which
// factor to verify and what code to use
multiFactors?: VerifyMfaParams[] | undefinedmultiFactors: [
{
multiFactorId: stringmultiFactorId,
multiFactorCode: stringmultiFactorCode: const totpCode: "123456"totpCode,
},
],
},
{
MutateOptions<User, Error, AuthParams, unknown>.onSuccess?: ((data: User, variables: AuthParams, context: unknown) => void) | undefinedonSuccess: () => {
// This callback will only fire after the user has clicked the magic link and the email has been verified
},
MutateOptions<User, Error, AuthParams, unknown>.onError?: ((error: Error, variables: AuthParams, context: unknown) => void) | undefinedonError: (error: Errorerror) => {
// Handle error
},
},
);
};
return <React.JSX.IntrinsicElements.div: React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>div>{/* Your UI components here */}</React.JSX.IntrinsicElements.div: React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>div>;
}
Step 3: Handle the Magic Link Redirect
When the user clicks the magic link in their email, your application needs to handle the redirect and complete the authentication.
The magic link will redirect to your application with a bundle parameter. You must submit this bundle to the authenticate
function to complete the authentication.
import React, { function useEffect(effect: React.EffectCallback, deps?: React.DependencyList): voidAccepts a function that contains imperative, possibly effectful code.
useEffect } from "react";
import { function useAuthenticate(mutationArgs?: UseAuthenticateMutationArgs): UseAuthenticateResultHook that provides functions and state for authenticating a user using a signer. It includes methods for both synchronous and asynchronous mutations. Useful if building your own UI components and want to control the authentication flow. For authenticate vs authenticateAsync, use authenticate when you want the hook the handle state changes for you, authenticateAsync when you need to wait for the result to finish processing.
This can be complex for magic link or OTP flows: OPT calls authenticate twice, but this should be handled by the signer.
useAuthenticate } from "@account-kit/react";
function function MagicLinkRedirect(): voidMagicLinkRedirect() {
const { const authenticate: UseMutateFunction<User, Error, AuthParams, unknown>authenticate } = function useAuthenticate(mutationArgs?: UseAuthenticateMutationArgs): UseAuthenticateResultHook that provides functions and state for authenticating a user using a signer. It includes methods for both synchronous and asynchronous mutations. Useful if building your own UI components and want to control the authentication flow. For authenticate vs authenticateAsync, use authenticate when you want the hook the handle state changes for you, authenticateAsync when you need to wait for the result to finish processing.
This can be complex for magic link or OTP flows: OPT calls authenticate twice, but this should be handled by the signer.
useAuthenticate();
const const handleMagicLinkRedirect: () => voidhandleMagicLinkRedirect = () => {
const const url: URLurl = new var URL: new (url: string | URL, base?: string | URL) => URLThe URL interface represents an object providing static methods used for creating object URLs.
URL
class is a global reference for import URL from 'node:url'
https://nodejs.org/api/url.html#the-whatwg-url-api
URL(var window: Window & typeof globalThiswindow.location: Locationlocation.Location.href: stringhref);
const const bundle: string | nullbundle = const url: URLurl.URL.searchParams: URLSearchParamssearchParams.URLSearchParams.get(name: string): string | nullReturns the first value associated to the given search parameter.
get("bundle");
// If there's no bundle parameter, this isn't a magic link redirect
if (!const bundle: string | nullbundle) return;
const authenticate: (variables: AuthParams, options?: MutateOptions<User, Error, AuthParams, unknown> | undefined) => voidauthenticate(
{
type: "email"type: "email",
bundle: stringbundle,
},
{
MutateOptions<User, Error, AuthParams, unknown>.onSuccess?: ((data: User, variables: AuthParams, context: unknown) => void) | undefinedonSuccess: () => {
// onSuccess only fires once the entire flow is done (email magic link + optional MFA).
// It still runs even if the final step completes in another tab/window.
},
MutateOptions<User, Error, AuthParams, unknown>.onError?: ((error: Error, variables: AuthParams, context: unknown) => void) | undefinedonError: (error: Errorerror) => {
// Handle error
},
},
);
};
// Call this function when the component mounts
function useEffect(effect: React.EffectCallback, deps?: React.DependencyList): voidAccepts a function that contains imperative, possibly effectful code.
useEffect(() => {
const handleMagicLinkRedirect: () => voidhandleMagicLinkRedirect();
}, []);
}