Built-in MFA
The built-in MFA flow is triggered automatically by the IAM anomaly detection system. When a user's session raises a flag during token rotation, the IAM service sends a verification email and returns HTTP 202 instead of rotating the tokens. The gateway ships two ready-to-use routes that handle the verification without any custom server code.
When MFA is triggered
The IAM service evaluates nine anomaly checks on every token rotation request. Any of the following conditions can trigger an MFA challenge:
| Condition | Description |
|---|---|
| New device | The canary_id cookie does not match the stored fingerprint |
| Idle session | More than 24 hours since the last request |
| Too many sessions | Active session count exceeds the configured limit |
| IP range change | Current IP is not in the same subnet as the stored IP |
| Elevated risk score | Suspicious activity score exceeds 25% of the ban threshold |
| Proxy or hosting | Request originates from proxy or hosting infrastructure the user has not previously allowed |
| Device fingerprint drift | Browser, OS, or device type does not match the stored values |
When any check fails, the IAM service generates a magic link JWT and a 7-digit verification code, sends both to the user's email, and returns { mfa: true, message: "..." } with status 202.
See IAM Anomaly Detection for the full list of checks and how the bypass window works after successful verification.
Route registration
The built-in MFA routes are registered by magicLinksRouter.
import { createRouter } from 'h3'
import { magicLinksRouter } from 'auth-h3client/v1'
const router = createRouter()
magicLinksRouter(router, 'api')
import { H3 } from 'h3'
import { magicLinksRouter } from 'auth-h3client/v2'
const app = new H3()
magicLinksRouter(app, 'api')
If you use the Nuxt module, these routes are registered automatically by defineAuthConfiguration in your Nitro plugin.
The prefix parameter prepends a path segment to all routes. With 'api' as the prefix:
| Method | Path | Handler | Purpose |
|---|---|---|---|
| GET | /api/auth/verify-mfa | verifyTempMfaLink | Validates the magic link |
| POST | /api/auth/verify-mfa | sendMfaCodeHandler | Submits the 7-digit code |
GET: magic link verification
When the user clicks the magic link in their email, the bounce route redirects them to your frontend verification page with query parameters (token, random, reason, visitor). The frontend code needs to sends a GET request to /api/auth/verify-mfa with those parameters, and all cookies identifiers.
The handler validates the request in this order:
- Sets
Cache-Control: no-storeto prevent caching. - Checks that the
canary_idcookie andtokenquery parameter are present. - Proxies the request to the IAM service for link signature verification.
On success, the handler returns the verification result from the IAM service:
{
"ok": true,
"date": "2026-04-12T10:00:00.000Z",
"data": {
"reason": "MAGIC_LINK_MFA_CHECKS",
"link": "MFA Code"
}
}
The useMagicLink composable handles this process automatically and reads the data fields. The frontend then can use link to confirm the flow type and renders the code input form.
If the link is invalid or expired, the handler returns a 404 error response.
POST: code submission
After the user enters the 7-digit code, the frontend submits it as a POST request. The route applies the following middleware before the handler runs:
- Link verification: the GET handler runs first as middleware to re-validate the magic link parameters.
- CSRF verification: validates the
X-CSRF-Tokenheader against the__Host-csrfcookie. - Content-Type: rejects requests without
Content-Type: application/json. - Body limit: rejects request bodies larger than 1 KB.
The handler then:
- Reads the
codefield from the request body. - Proxies the code and the magic link parameters to the IAM
/auth/verify-mfaendpoint. - On success, the IAM service verifies the code, rotates both tokens, and returns the new credentials.
- The handler sets the
__Secure-aanda-iatcookies with the new access token and forwards theSet-Cookieheaders from the IAM response (containing the newsessioncookie).
The response depends on the Accept header:
Accept: application/json: returns{ "ok": true, "redirectTo": "/dashboard" }where the redirect URL comes from theonSuccessRedirectconfiguration.- Otherwise: responds with HTTP 303 redirect to
onSuccessRedirect.
Error responses
| IAM status | Gateway response | Meaning |
|---|---|---|
| 200 | 200 or 303 redirect | Code verified, tokens rotated |
| 400 | 400 | Invalid or expired code |
| 401 | 401 | Invalid or expired code |
| 403 | 403 | Missing session cookies or banned user |
| 429 | 429 with Retry-After | Rate limited |
| 500 | 500 | Server error |
Deduplication
The code submission handler is wrapped with defineDeduplicatedEventHandler. If two identical requests arrive simultaneously for the same session, only one is processed. The second request waits for the result of the first. This prevents race conditions from double-submit on the frontend.
What happens after verification
After successful MFA verification, the IAM service updates users.last_mfa_at. This timestamp creates a bypass window (configurable on the IAM service) during which certain anomaly checks are relaxed. For example, the session count check is skipped for 5 minutes after MFA completion, allowing the user to open multiple tabs without triggering another challenge.
See IAM Anomaly Detection for how the bypass window affects each check.