Client-Side MFA

How to use useMagicLink and executeRequest to build MFA verification pages, handle the bounce redirect, and submit verification codes from Vue components.

The client-side MFA integration consists of two pieces: the bounce route that redirects email links to your frontend, and the verification page where the user confirms their identity by entering the code.

The useMagicLink composable and all Vue components described in this page are part of the auth-h3client/client entry point and require a Nuxt application. For H3 or Nitro setups without the Nuxt module, implement link parameter parsing and server calls directly using fetch or your preferred HTTP client as described in the Client code section.


Bounce route

When the IAM service sends a magic link email, the link points to the bounce path on your gateway (default: /auth/bounce). bounceRouter registers a GET handler at this path that reads the query parameters and redirects to your frontend verification page (default: /auth/verify).

Both paths are configurable via configuration():

import { configuration } from 'auth-h3client/v1'

configuration({
  magicLinkBouncePath: '/auth/bounce',
  magicLinkRedirectPath: '/auth/verify',
  // ...
})

The redirect URL carries the original query parameters: token, random, reason, and visitor.


Client code

On landing at the verification page, your client must:

  1. Read token, random, reason, and visitor from the URL query string.
  2. Detect the active flow via the reason value.
  3. Send a GET request to the corresponding server endpoint with those parameters and the user's cookies.

Minimal example calling /api/auth/verify-mfa:

const params = new URLSearchParams(window.location.search)
const { token, random, reason, visitor } = Object.fromEntries(params)

const res = await fetch(
  `/api/auth/verify-mfa?token=${token}&random=${random}&reason=${reason}&visitor=${visitor}`,
  { credentials: 'include' }
)
const data = await res.json()

// data: { ok: true, date: string, data: { reason: string, link: string } }

On success the server returns { ok: true, data: { reason, link } }. Use link to confirm the flow type and render the appropriate form. On failure (invalid or expired link) the server returns 404.


useMagicLink is a Nuxt composable that handles the full verification page logic. It reads the query parameters from useRoute().query, selects the server endpoint based on reason, validates the link with the server, and returns the verified data. The result is cached with useAsyncData so the server call is not repeated during hydration.

const data = await useMagicLink()

Built-in routing

ReasonEndpoint
MAGIC_LINK_MFA_CHECKS/api/auth/verify-mfa
PASSWORD_RESET/api/auth/reset-password
change_email/api/auth/update-email

The reason comparison is case-insensitive.

Custom flows

For custom MFA flows, the reason does not match any built-in value. Pass the path to your custom verification GET endpoint:

const data = await useMagicLink('/api/account/delete-verify')

If no path is provided and the reason does not match a built-in flow, the composable throws a 404 error.

Return value

interface MagicLinkData {
  token: string    // temporary verification token
  random: string   // cryptographic hash
  visitor: string  // visitor identifier
  reason: string   // flow reason (e.g. 'PASSWORD_RESET', 'delete_account')
  link: string     // verified action type ('Password Reset', 'MFA Code', 'Custom MFA')
}

On failure (invalid link, expired token, missing parameters), the composable throws a Nuxt 404 error that renders your error page.


Verification page (Nuxt)

Create a single page at pages/auth/verify.vue. useMagicLink determines which flow is active and your page renders the appropriate form based on data.reason.

pages/auth/verify.vue
<script setup lang="ts">
const data = await useMagicLink('/api/custom-verify')
</script>

<template>
  <div v-if="data.reason.toLowerCase() === 'password_reset'">
    <PasswordResetForm v-bind="data" />
  </div>

  <div v-else-if="data.reason.toLowerCase() === 'magic_link_mfa_checks'">
    <MfaCodeForm v-bind="data" />
  </div>

  <div v-else-if="data.reason.toLowerCase() === 'change_email'">
    <EmailChangeForm v-bind="data" />
  </div>

  <div v-else>
    <CustomActionForm v-bind="data" />
  </div>
</template>

Submitting the code

Each form component submits the 7-digit code to the appropriate POST endpoint using executeRequest. The original query parameters must be passed back as query string values.

components/MfaCodeForm.vue
<script setup lang="ts">
const props = defineProps<{
  token: string
  random: string
  visitor: string
  reason: string
}>()

const code = ref('')
const error = ref('')

async function submit() {
  const query = {
    token: props.token,
    random: props.random,
    visitor: props.visitor,
    reason: props.reason,
  }

  const result = await executeRequest<{ ok: boolean; redirectTo: string }>(
    '/api/auth/verify-mfa',
    'POST',
    { code: code.value },
    {},
    { query }
  )

  if (result.ok) {
    await navigateTo(result.data.redirectTo)
  } else {
    error.value = result.reason
  }
}
</script>

<template>
  <form @submit.prevent="submit">
    <p>Enter the 7-digit code from your email.</p>

    <input
      v-model="code"
      type="text"
      inputmode="numeric"
      maxlength="7"
      pattern="\d{7}"
    />

    <p v-if="error">{{ error }}</p>

    <button type="submit" :disabled="code.length !== 7">
      Verify
    </button>
  </form>
</template>

For the password reset form, submit to POST /api/auth/reset-password with password, confirmedPassword, and code in the body. For the email change form, submit to POST /api/auth/update-email with code, email (current), newEmail, and password. For custom flows, submit to your custom POST endpoint.


Detecting MFA requirement (Nuxt)

The useAuthData composable surfaces the MFA state set by ensureValidCredentials:

const auth = await useAuthData()

if (auth.value.mfaRequired) {
  // The IAM service returned 202 during token rotation.
  // The verification email has already been sent.
  // Direct the user to check their email.
}

The mfaRequired state means the IAM service has already sent the verification email. The user clicks the link, which hits the bounce route and redirects to your verification page. No additional API call is needed to trigger the email.

For H3 or Nitro setups, check for a 202 response or { mfaRequired: true } body on any protected route and redirect the user to your verification page accordingly.


Custom flow detection

For custom MFA flows initiated by askForMfaFlow, the IAM service may return MFA_REQUIRED if the current session has anomalies. The user must complete the standard built-in MFA flow before the custom flow can proceed.

Handle this in your initiation endpoint:

const result = await askForMfaFlow(event, log, 'delete_account', random)

if (!result.ok && result.code === 'MFA_REQUIRED') {
  setResponseStatus(event, 202)
  return { mfaRequired: true, message: result.reason }
}
Logo