MFA

Email OTP adaptive MFA triggered by anomaly detection, custom MFA flows for sensitive actions, OTP code generation and verification, and the full lifecycle in the IAM service.

The IAM service provides two MFA mechanisms: adaptive MFA triggered automatically by anomaly detection when a suspicious session event occurs, and custom MFA flows that your application triggers explicitly for sensitive user actions such as changing a password, transferring funds, or deleting an account.

Both mechanisms share the same infrastructure: a signed temporary JWT link, a 7-digit OTP code stored as a SHA-256 hash with a 7-minute expiry, multi-layer rate limiting, and an atomic verification function that rotates tokens on success.


Adaptive MFA lifecycle

When strangeThings() determines that a session event is suspicious (IP change, device mismatch, session limit exceeded, or proxy/hosting anomaly), it returns reqMFA: true. The calling controller then invokes sendTempMfaLink, which triggers the full adaptive MFA flow.

sendTempMfaLink generates a unique JTI (crypto.randomUUID() + crypto.randomBytes(64).toString('hex'), producing a 164-character string), a random challenge (crypto.randomBytes(128)), and hashes the challenge with SHA-256. It then signs a temporary JWT with purpose MAGIC_LINK_MFA_CHECKS and a subject of MAGIC_LINK_MFA_CHECKS_{visitor}.

OTP generation

The function calls generateMfaCode (described in OTP generation) to create a 7-digit code, hash it, and store it in the mfa_codes table tied to the user's current refresh token.

Email delivery

The function fetches the user's email and display name from the database, then calls mfaEmail to render the OTP email template with the code, magic link URL, and contextual metadata (device, browser, location). The email subject is formatted as Security Code - {code}.

User interaction

The user receives an email with the 7-digit code displayed prominently and a "Verify Here" button linking to the magic link URL. They can either click the link (which pre-fills the code through the bounce page) or copy the code manually into the verification form.

Verification

The user submits the code to the MFA verification route. verifyMfaCode validates the code, rotates the user's tokens, updates their fingerprints, and records last_mfa_at on the user record. The flow completes with new access and refresh tokens.

import { sendTempMfaLink } from '@riavzon/auth'

const result = await sendTempMfaLink(
  { userId: user.id, visitor: user.visitor_id },
  rawRefreshToken,
  req.ip!,
  res,
  {
    device: 'Safari on iOS',
    browser: 'Safari 17',
    location: 'Paris, FR',
  }
)

if (result === 'rate_limited') {
  res.status(429).json({ error: 'Too many MFA requests. Try again later.' })
  return
}

The function returns one of three values:

Return valueMeaning
trueEmail sent successfully
falseAn error occurred during code generation or email delivery
'rate_limited'One of the three rate limiters rejected the request

The metadata parameter accepts the EmailMetaDataOTP shape with three fields: device, browser, and location. The date field shown in the email template is generated server-side automatically.

sendTempMfaLink applies three independent rate limiters before sending: a global email limiter (keyed 'global_emails'), a per-user limiter (keyed 'user_{userId}'), and a per-IP limiter. If any one of them rejects the request, the function returns 'rate_limited' without sending.

OTP generation

generateMfaCode produces a cryptographically secure 7-digit numeric code using crypto.randomInt(1_000_000, 9_999_999), zero-padded to 7 characters. The raw code is returned to the caller for email delivery, while a SHA-256 hash of the code is stored in the database.

import { generateMfaCode } from '@riavzon/auth'

const result = await generateMfaCode(log, sessionToken, userId, jti)

if (result.ok) {
  // result.code  — the raw 7-digit string, send to the user
  // result.data  — insertion metadata
}

The function uses a database transaction with idempotency logic:

Check for existing code

Before generating a new code, the function queries the mfa_codes table for an unexpired, unused code belonging to the same user. If one exists, it skips insertion and returns ok: true with code: undefined to signal that a valid code is already in flight.

Clean up old codes

Any expired or previously used codes for the user are deleted from the table.

Insert new code

A new row is inserted with the SHA-256 hashed code, the hashed session token, the JTI, the user ID, and an expiry timestamp set to 7 minutes from now. The used column defaults to 0.

The mfa_codes table

Each row in the mfa_codes table represents a single pending OTP challenge. The table enforces uniqueness on user_id, code_hash, jti, and token, meaning each user can have at most one active code at any time.

ColumnPurpose
user_idThe user the code belongs to. Foreign key to users.id with CASCADE delete.
tokenThe hashed refresh token that was active when the code was issued. Foreign key to refresh_tokens.token with CASCADE delete. If the token is revoked, the code is automatically invalidated.
jtiThe JWT ID from the temporary link. Uniquely identifies the verification session.
code_hashSHA-256 hash of the 7-digit code. Never stores the raw code.
expires_atUTC timestamp. The code is invalid after this time (7 minutes from creation).
usedBoolean flag. Set to 0 on creation, never updated (codes are deleted on use, not marked used).
The token foreign key with CASCADE delete means revoking a user's refresh token (through logout, token rotation, or admin action) automatically invalidates any pending MFA code tied to that token.

OTP verification

verifyMfaCode is the central verification function used by all MFA flows: adaptive MFA, custom MFA, and email update. It validates the submitted code, performs the database lookup, and on success rotates tokens, updates fingerprints, and records the verification timestamp.

Rate limiting

Verification is protected by four rate limiters and three consecutive-failure caches that work together to prevent brute-force attacks:

LimiterKeyPurpose
uniLimiter{IP}_{JTI}Limits attempts per IP and verification session
ipLimit{code_hash}Blocks a specific code hash after verification (prevents replay)
usedJtiLimiter{JTI}Block-only limiter that prevents reuse of a verified JTI
Consecutive cacheKeyDurationPurpose
consecutiveForSubmittedHash{code_hash}10 minutesTracks repeated submissions of the same wrong code
consecutiveForSlowDown{IP}10 minutesProgressive slowdown on repeated failures from same IP
consecutiveForJti{JTI}20 minutesTracks failures per verification session

Verification flow

Input validation

The submitted code is validated with a Zod schema to ensure it matches the expected format before any database query runs.

Hash and lookup

The submitted code is SHA-256 hashed. The function queries the mfa_codes table for a row matching the JTI, the code hash, an expires_at in the future, and used = 0. The query uses FOR UPDATE to lock the row.

Atomic delete

If a matching row is found, it is deleted with a LIMIT 1 constraint. The function checks that affectedRows === 1 to confirm the delete was atomic. This prevents time-of-check/time-of-use (TOCTOU) races where two concurrent requests could both verify the same code.

Post-verification blocking

The verified code hash is blocked in ipLimit for 10 minutes, and the JTI is blocked in usedJtiLimiter for 20 minutes. This prevents replay of the same code or reuse of the verification session.

Optional callback

If the caller passed an onSuccess callback (used by the email update flow to change the email address), it executes inside the same database transaction. If the callback throws, the entire transaction rolls back and the code is not consumed.

User record update

The function updates users.last_mfa_at to UTC_TIMESTAMP() and sets proxy_allowed = 1 and hosting_allowed = 1 on the user's visitor record. This grants the verified device a trusted status.

Fingerprint update

The function calls updateVisitors to refresh the user's device fingerprint data with the current request metadata.

Token rotation

The old refresh token is verified and revoked. If revokeAllTokensOnSuccess is true (used by the email update flow), all of the user's refresh tokens are revoked instead of just the current one. New access and refresh tokens are generated with updated claims.

Anomaly Cache delete

Deletes the cache entry from anomaliesCache of the anomaly that triggered this flow.

New iat and session cookies are set on the response. The function returns { accessToken, accessIat } by default, or the full token metadata if returnMetaData is true.

Cleanup

All rate limiters and consecutive caches for the session are reset on success, allowing the user to proceed without accumulated penalties.

A failed verification attempt increments all three consecutive caches. If any cache exceeds its threshold, subsequent attempts are delayed or blocked even if the code is correct. The user must wait for the cache durations to expire (10 to 20 minutes) before retrying.

Custom MFA flows

Custom MFA flows let you protect any sensitive action behind a second-factor check. The user must be authenticated (valid access and refresh tokens) to initiate a custom flow. Unlike adaptive MFA, which triggers automatically, custom flows are initiated by your application through a service-to-service call.

Reserved reasons

The following reason strings are reserved for internal flows and cannot be used with the custom MFA API:

ReasonInternal flow
MAGIC_LINK_MFA_CHECKSAdaptive MFA
PASSWORD_RESETPassword reset link
PASSWORD_RESET_FLOWPassword reset verification
EMAIL_MFA_FLOWEmail update verification

Initiating a custom flow

POST /custom/mfa/:reason initiates a custom MFA flow. The controller enforces IP restrictions (the request must originate from service.clientIp or service.proxy.ipToTrust), applies four layers of rate limiting, and validates the parameters with Zod.

The reason comes from the URL path parameter and random comes from the query string. There is no request body:

POST /custom/mfa/payment?random=<254-500 char string>
Authorization: Bearer <access_token>
Cookie: session=...; canary_id=...

The controller extracts device fingerprint metadata from the request (device, browser, location via IP geolocation), then calls generateCustomMfaFlow which:

  1. Generates a unique JTI (random UUID + 64 bytes of crypto.randomBytes)
  2. Hashes the random challenge with SHA-256
  3. Signs a temporary JWT with purpose set to the reason and subject set to {reason}_{visitor}
  4. Builds the verification URL using pathForCustomFlow
  5. Calls generateMfaCode to create and store the OTP
  6. Sends the OTP email via mfaEmail

The endpoint enforces a 3-second minimum response time to prevent timing-based user enumeration, and always returns HTTP 200 on success.

Verifying a custom flow

After the user clicks the email link, two routes handle verification:

RouteMethodPurpose
/auth/verify-custom-mfaGETValidates the signed link token via customMfaFlowsVerification middleware
/auth/verify-custom-mfaPOSTAccepts { "code": "1234567" } and delegates to verifyMfaCode with returnMetaData: true

The POST handler is a thin wrapper: it checks for JSON content type, extracts the code from the request body, and passes it to verifyMfaCode. On success, the response includes the full token metadata (access token, refresh token, IAT) so the calling application can update its session.

Wiring custom MFA into routes

Import the verification middleware and controller to protect any route with a custom MFA check:

import {
  customMfaFlowsVerification,
  initCustomMfaFlow,
  verifyCustomMfa,
} from '@riavzon/auth'
The custom MFA flow uses the same mfa_codes table, the same verifyMfaCode function, and the same rate limiters as adaptive MFA. The only differences are the JWT purpose claim, the URL path, and the IP restriction on initiation.

Email update flow

The email update flow is a specialized use of the MFA system that requires the user to verify their identity with both their current password and a valid OTP code before their email address is changed.

Initiation

The client application initiates a custom MFA flow with purpose change_email or uses the built-in email MFA flow, sending the user a verification email to their current address.

Submission

The user submits a request with three fields:

{
  "email": "[email protected]",
  "newEmail": "[email protected]",
  "password": "current-password",
  "code": "1234567"
}

Password verification

The controller validates the request with Zod, confirms that req.link.purpose === 'change_email', and verifies the current password against the stored Argon2 hash.

MFA verification with callback

The controller delegates to verifyMfaCode with two special options:

  • revokeAllTokensOnSuccess: true - All of the user's refresh tokens are revoked (not just the current one), forcing re-authentication on every device
  • onSuccess callback - Executes inside the verification transaction to update the user's email address in the database

Notification

After successful verification and email change, the service sends a notification email to the old email address via sendEmailNotification with the subject "Your Email Has Changed", alerting the user in case the change was unauthorized.

Revoking all tokens on email change is a deliberate security measure. If an attacker gained access to the account and changed the email, the legitimate user's sessions on other devices are invalidated, and the notification to the old address serves as an alert.

Post-MFA bypass window

After a successful MFA verification, the service records last_mfa_at on the user record with UTC_TIMESTAMP(). For the duration configured in byPassAnomaliesFor, anomaly checks that would normally require MFA (such as exceeding the session limit or connecting from a new IP) are skipped. This prevents users from being immediately challenged again on a device they have just verified.

The bypass also sets proxy_allowed = 1 and hosting_allowed = 1 on the user's visitor record, granting trusted status to connections from proxies or hosting providers that would otherwise trigger anomaly detection.


Routes reference

RouteMethodPurpose
/auth/verify-mfaGETValidates the adaptive MFA magic link token
/auth/verify-mfaPOSTSubmits OTP code for adaptive MFA verification
/custom/mfa/:reasonPOSTInitiates a custom MFA flow (service-to-service, IP restricted)
/auth/verify-custom-mfaGETValidates the custom MFA magic link token
/auth/verify-custom-mfaPOSTSubmits OTP code for custom MFA verification
/update/emailPOSTSubmits new email + password + OTP for email change
/auth/forgot-passwordPOSTInitiates a password reset email
/auth/reset-passwordGETValidates the password reset magic link
/auth/reset-passwordPOSTSubmits the new password after link validation

Configuration reference

OTP settings

mfa.codeLength
number
The number of digits in the OTP code. Generated via crypto.randomInt with range 1_000_000 to 9_999_999.
mfa.codeExpiry
number
Time in milliseconds before an OTP code expires. Default is 7 minutes (420,000 ms).

Bypass window

jwt.refresh_tokens.byPassAnomaliesFor
number
Duration in milliseconds after a successful MFA verification during which anomaly checks are skipped for the verified visitor. Default is 5 minutes (300,000 ms).

Rate limiters

globalEmailLimiter
RateLimiterMySQL
Global rate limiter for all outbound MFA emails across all users. Keyed by 'global_emails'. Prevents email-sending abuse at the system level.
userIdLimiter
RateLimiterMySQL
Per-user rate limiter for MFA email requests. Keyed by 'user_{userId}'. Prevents a single user from triggering excessive emails.
ipLimiter
RateLimiterMySQL
Per-IP rate limiter for MFA email and verification requests. Prevents a single IP from flooding the system.
uniLimiter
RateLimiterMySQL
Composite rate limiter keyed by {IP}_{JTI} for verification attempts, or {IP}_{random}_{reason} for custom flow initiation. The most granular limiter.
emailLimiter
RateLimiterMySQL
Keyed by {random}_{reason} for custom flows. Limits initiation attempts per challenge and reason pair.

Email images

Image URLs for the OTP email template. Configured under magic_links.emailImages.otp. See the emails configuration reference for the full list of image fields.

Logo