Custom MFA Flow

How to implement step-up MFA verification for sensitive actions like account deletion, using askForMfaFlow to initiate and handler wrappers to verify.

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:

  1. Initiation endpoint: your authenticated handler generates a cryptographic buffer, calls askForMfaFlow, and tells the client to check their email.
  2. Verification endpoint (GET): wraps your handler with defineVerifiedMagicLinkGetHandler to verify the magic link when the user clicks it.
  3. Verification endpoint (POST): wraps your handler with defineMfaCodeVerifierHandler to 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 }
})
The 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:

  1. Reads canary_id, session, and __Secure-a cookies from the request.
  2. Validates the Buffer length and reason string length (max 100 characters).
  3. Sends POST /custom/mfa/{reason}?random={hex} to the IAM service with the session cookies and access token.
  4. 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:

CodeMeaning
INVALID_CREDENTIALSMissing session cookies or IAM rejected the request
HASHBuffer is not a Buffer type, or hex length is not between 254 and 500 characters
REASONReason string exceeds 100 characters
MFA_REQUIREDThe current session has an anomaly: the user must complete the standard MFA flow first
FORBIDDENUser is banned or blacklisted
RATE_LIMITToo many requests. retryAfter contains the seconds to wait
AUTH_SERVER_ERRORIAM service returned 500 or an unexpected response
AUTH_REJECTEDIAM service returned success but the result indicates rejection
UNEXPECTED_ERRORUnhandled exception

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 }
})

The wrapper performs these checks before your handler runs:

  1. Asserts the request method is GET.
  2. Validates that canary_id, session, and __Secure-a cookies are present.
  3. Validates the query parameters (visitor, token, random, reason) against the VerificationLinkSchema.
  4. Sends GET /auth/verify-custom-mfa/?visitor={}&token={}&random={}&reason={} to the IAM service.
  5. On success, sets event.context.link and event.context.reason and executes your handler.
This wrapper does not verify a CSRF token because it handles GET requests arriving from an email link, where the __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' }
})

The wrapper performs these checks before your handler runs:

  1. Verifies the CSRF token (wraps with defineVerifiedCsrfHandler).
  2. Asserts the request method is POST.
  3. Limits the request body to 8 MB.
  4. Validates canary_id and session cookies.
  5. Validates the query parameters against the VerificationLinkSchema.
  6. Validates event.context.body.code as a 7-digit numeric string.
  7. Sends POST /auth/verify-custom-mfa to the IAM service with the code and link parameters.
  8. On success, applies token rotation and sets event.context.limitedMetaData.
  9. 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_CHECKS
  • PASSWORD_RESET
  • change_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.

Logo