Custom MFA Flow
Custom MFA flows let you require email-based verification for any sensitive action in your application. Unlike the built-in MFA flow that is triggered automatically by anomaly detection, custom flows are initiated by your server code when the user attempts an action you want to protect.
Overview
A custom MFA flow has three parts:
- Initiation endpoint: your authenticated handler generates a cryptographic buffer, calls
askForMfaFlow, and tells the client to check their email. - Verification endpoint (GET): wraps your handler with
defineVerifiedMagicLinkGetHandlerto verify the magic link when the user clicks it. - Verification endpoint (POST): wraps your handler with
defineMfaCodeVerifierHandlerto verify the 7-digit code and rotate tokens before executing your action.
You can implement either or both verification methods depending on your needs.
Initiating the flow
Create an authenticated endpoint that calls askForMfaFlow. This function requires the current session cookies (canary_id, session, __Secure-a), a reason string, and a Buffer of cryptographic random data.
import { randomBytes } from 'node:crypto'
import { defineAuthenticatedEventHandler, askForMfaFlow, getLogger } from 'auth-h3client/v1'
export default defineAuthenticatedEventHandler(async (event) => {
const log = getLogger().child({ service: 'api', action: 'delete-account' })
const random = randomBytes(128)
const result = await askForMfaFlow(event, log, 'delete_account', random)
if (!result.ok) {
throw createError({ statusCode: 400, message: result.reason })
}
return { message: result.data }
})
import { randomBytes } from 'node:crypto'
import { defineAuthenticatedEventHandler, askForMfaFlow, getLogger } from 'auth-h3client/v2'
export default defineAuthenticatedEventHandler(async (event) => {
const log = getLogger().child({ service: 'api', action: 'delete-account' })
const random = randomBytes(128)
const result = await askForMfaFlow(event, log, 'delete_account', random)
if (!result.ok) {
throw new HTTPError({ status: 400, message: result.reason })
}
return { message: result.data }
})
import { randomBytes } from 'node:crypto'
export default defineAuthenticatedEventHandler(async (event) => {
const log = getLogger().child({ service: 'api', action: 'delete-account' })
const random = randomBytes(128)
const result = await askForMfaFlow(event, log, 'delete_account', random)
if (!result.ok) {
throw createError({ statusCode: 400, message: result.reason })
}
return { message: result.data }
})
random parameter must be a Buffer. The function converts it to hex internally and validates that the hex string is between 254 and 500 characters long. A randomBytes(128) call produces a 256-character hex string, which fits within this range.How askForMfaFlow works
The function:
- Reads
canary_id,session, and__Secure-acookies from the request. - Validates the
Bufferlength and reason string length (max 100 characters). - Sends
POST /custom/mfa/{reason}?random={hex}to the IAM service with the session cookies and access token. - The IAM service generates a magic link and 7-digit code, sends both to the user's email, and returns success.
The request is deduplicated with lockAsyncAction keyed on canary_id and reason to prevent concurrent calls for the same session.
Response
On success:
{ ok: true, date: '...', data: 'Please check your email to complete the action.' }
On failure, the code field identifies the error:
| Code | Meaning |
|---|---|
INVALID_CREDENTIALS | Missing session cookies or IAM rejected the request |
HASH | Buffer is not a Buffer type, or hex length is not between 254 and 500 characters |
REASON | Reason string exceeds 100 characters |
MFA_REQUIRED | The current session has an anomaly: the user must complete the standard MFA flow first |
FORBIDDEN | User is banned or blacklisted |
RATE_LIMIT | Too many requests. retryAfter contains the seconds to wait |
AUTH_SERVER_ERROR | IAM service returned 500 or an unexpected response |
AUTH_REJECTED | IAM service returned success but the result indicates rejection |
UNEXPECTED_ERROR | Unhandled exception |
Magic link verification
Wrap a GET handler with defineVerifiedMagicLinkGetHandler to verify the magic link when the user clicks it from their email.
import { defineVerifiedMagicLinkGetHandler } from 'auth-h3client/v1'
export const deleteVerifyGet = defineVerifiedMagicLinkGetHandler(async (event) => {
const { link, reason } = event.context
return { ok: true, reason }
})
import { defineVerifiedMagicLinkGetHandler } from 'auth-h3client/v2'
export const deleteVerifyGet = defineVerifiedMagicLinkGetHandler(async (event) => {
const { link, reason } = event.context
return { ok: true, reason }
})
export default defineVerifiedMagicLinkGetHandler(async (event) => {
const { link, reason } = event.context
return { ok: true, reason }
})
The wrapper performs these checks before your handler runs:
- Asserts the request method is GET.
- Validates that
canary_id,session, and__Secure-acookies are present. - Validates the query parameters (
visitor,token,random,reason) against theVerificationLinkSchema. - Sends
GET /auth/verify-custom-mfa/?visitor={}&token={}&random={}&reason={}to the IAM service. - On success, sets
event.context.linkandevent.context.reasonand executes your handler.
__Host-csrf cookie may not be present yet.Code verification
Wrap a POST handler with defineMfaCodeVerifierHandler to verify the 7-digit code and rotate tokens before executing your sensitive action.
import { defineMfaCodeVerifierHandler } from 'auth-h3client/v1'
export const deleteVerifyPost = defineMfaCodeVerifierHandler(async (event) => {
// CSRF verified, code verified, tokens rotated
const { limitedMetaData } = event.context
await deleteAccount(limitedMetaData.userId)
return { ok: true, message: 'Account deleted' }
})
import { defineMfaCodeVerifierHandler } from 'auth-h3client/v2'
export const deleteVerifyPost = defineMfaCodeVerifierHandler(async (event) => {
const { limitedMetaData } = event.context
await deleteAccount(limitedMetaData.userId)
return { ok: true, message: 'Account deleted' }
})
export default defineMfaCodeVerifierHandler(async (event) => {
const { limitedMetaData } = event.context
await deleteAccount(limitedMetaData.userId)
return { ok: true, message: 'Account deleted' }
})
The wrapper performs these checks before your handler runs:
- Verifies the CSRF token (wraps with
defineVerifiedCsrfHandler). - Asserts the request method is POST.
- Limits the request body to 8 MB.
- Validates
canary_idandsessioncookies. - Validates the query parameters against the
VerificationLinkSchema. - Validates
event.context.body.codeas a 7-digit numeric string. - Sends
POST /auth/verify-custom-mfato the IAM service with the code and link parameters. - On success, applies token rotation and sets
event.context.limitedMetaData. - Executes your handler.
Request format
The client sends the code in the request body and the link parameters as query string values:
POST /api/account/delete-verify?visitor=123&token=abc&random=def&reason=delete_account
Content-Type: application/json
X-CSRF-Token: <token>
{ "code": "1234567" }
Reserved reasons
The IAM service rejects custom MFA requests that use reserved reason strings. These are used internally by the built-in flows:
MAGIC_LINK_MFA_CHECKSPASSWORD_RESETchange_email
Use a different reason string for your custom flows.
Client-side integration
The useMagicLink composable (Nuxt) accepts a path parameter for custom flows. When the reason query parameter does not match any built-in reason, it uses the provided path to validate the link:
// The reason is 'delete_account', which is not a built-in reason.
// useMagicLink falls back to the provided path.
const data = await useMagicLink('/api/account/delete-verify')
For H3 or Nitro setups without the Nuxt module, read the token, random, reason, and visitor query parameters from the request directly and call your GET verification endpoint with them before presenting the code input.
Your verification page renders the appropriate UI based on data.reason and submits the code to your POST endpoint.
See Client-Side MFA for the full client-side implementation.