Client-Side MFA
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',
// ...
})
import { configuration } from 'auth-h3client/v2'
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:
- Read
token,random,reason, andvisitorfrom the URL query string. - Detect the active flow via the
reasonvalue. - 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
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
| Reason | Endpoint |
|---|---|
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.
<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.
<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 }
}
const result = await askForMfaFlow(event, log, 'delete_account', random)
if (!result.ok && result.code === 'MFA_REQUIRED') {
event.res.status = 202
return { mfaRequired: true, message: result.reason }
}