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:

  1. The user enters their email address to request a magic link
  2. 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)
  3. After entering a valid TOTP code, a magic link is sent to their email
  4. The user clicks the magic link from email to complete authentication
  5. 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): UseAuthenticateResult

Hook 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 MfaRequiredError
MfaRequiredError
} 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.Element
MagicLinkWithMFA
() {
const {
const authenticate: UseMutateFunction<User, Error, AuthParams, unknown>
authenticate
} =
function useAuthenticate(mutationArgs?: UseAuthenticateMutationArgs): UseAuthenticateResult

Hook 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) => void
handleInitialAuthentication
= (
email: string
email
: string) => {
const authenticate: (variables: AuthParams, options?: MutateOptions<User, Error, AuthParams, unknown> | undefined) => void
authenticate
(
{
type: "email"
type
: "email",
emailMode?: "otp" | "magicLink" | undefined
emailMode
: "magicLink",
email: string
email
,
}, {
MutateOptions<User, Error, AuthParams, unknown>.onSuccess?: ((data: User, variables: AuthParams, context: unknown) => void) | undefined
onSuccess
: () => {
// This callback only fires when the entire auth flow is complete // (user clicked magic link and completed MFA if required)
var console: Console

The 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) | undefined
onError
: (
error: Error
error
) => {
// If MFA is required the attempt will result in an MfaRequiredError if (
error: Error
error
instanceof
class MfaRequiredError
MfaRequiredError
) {
const {
const multiFactorId: string
multiFactorId
} =
error: MfaRequiredError
error
.
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
>;
}

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): UseAuthenticateResult

Hook 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.Element
MagicLinkWithMFA
() {
const {
const authenticate: UseMutateFunction<User, Error, AuthParams, unknown>
authenticate
} =
function useAuthenticate(mutationArgs?: UseAuthenticateMutationArgs): UseAuthenticateResult

Hook 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) => void
handleMfaSubmission
= (
email: string
email
: string) => {
const authenticate: (variables: AuthParams, options?: MutateOptions<User, Error, AuthParams, unknown> | undefined) => void
authenticate
(
{
type: "email"
type
: "email",
emailMode?: "otp" | "magicLink" | undefined
emailMode
: "magicLink",
email: string
email
,
// The multiFactors array tells the authentication system which // factor to verify and what code to use
multiFactors?: VerifyMfaParams[] | undefined
multiFactors
: [
{
multiFactorId: string
multiFactorId
,
multiFactorCode: string
multiFactorCode
:
const totpCode: "123456"
totpCode
,
}, ], }, {
MutateOptions<User, Error, AuthParams, unknown>.onSuccess?: ((data: User, variables: AuthParams, context: unknown) => void) | undefined
onSuccess
: () => {
// 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) | undefined
onError
: (
error: Error
error
) => {
// 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
>;
}

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): void

Accepts a function that contains imperative, possibly effectful code.

useEffect
} from "react";
import {
function useAuthenticate(mutationArgs?: UseAuthenticateMutationArgs): UseAuthenticateResult

Hook 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(): void
MagicLinkRedirect
() {
const {
const authenticate: UseMutateFunction<User, Error, AuthParams, unknown>
authenticate
} =
function useAuthenticate(mutationArgs?: UseAuthenticateMutationArgs): UseAuthenticateResult

Hook 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: () => void
handleMagicLinkRedirect
= () => {
const
const url: URL
url
= new
var URL: new (url: string | URL, base?: string | URL) => URL

The URL interface represents an object providing static methods used for creating object URLs.

MDN Reference

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 globalThis
window
.
location: Location
location
.
Location.href: string

Returns the Location object's URL.

Can be set, to navigate to the given URL.

MDN Reference

href
);
const
const bundle: string | null
bundle
=
const url: URL
url
.
URL.searchParams: URLSearchParams
searchParams
.
URLSearchParams.get(name: string): string | null

Returns the first value associated to the given search parameter.

MDN Reference

get
("bundle");
// If there's no bundle parameter, this isn't a magic link redirect if (!
const bundle: string | null
bundle
) return;
const authenticate: (variables: AuthParams, options?: MutateOptions<User, Error, AuthParams, unknown> | undefined) => void
authenticate
(
{
type: "email"
type
: "email",
bundle: string
bundle
,
}, {
MutateOptions<User, Error, AuthParams, unknown>.onSuccess?: ((data: User, variables: AuthParams, context: unknown) => void) | undefined
onSuccess
: () => {
// 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) | undefined
onError
: (
error: Error
error
) => {
// Handle error }, }, ); }; // Call this function when the component mounts
function useEffect(effect: React.EffectCallback, deps?: React.DependencyList): void

Accepts a function that contains imperative, possibly effectful code.

useEffect
(() => {
const handleMagicLinkRedirect: () => void
handleMagicLinkRedirect
();
}, []); }

Next Steps