MFA
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.
Link generation
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.
sendTempMfaLink
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 value | Meaning |
|---|---|
true | Email sent successfully |
false | An 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.
| Column | Purpose |
|---|---|
user_id | The user the code belongs to. Foreign key to users.id with CASCADE delete. |
token | The 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. |
jti | The JWT ID from the temporary link. Uniquely identifies the verification session. |
code_hash | SHA-256 hash of the 7-digit code. Never stores the raw code. |
expires_at | UTC timestamp. The code is invalid after this time (7 minutes from creation). |
used | Boolean flag. Set to 0 on creation, never updated (codes are deleted on use, not marked used). |
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:
| Limiter | Key | Purpose |
|---|---|---|
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 cache | Key | Duration | Purpose |
|---|---|---|---|
consecutiveForSubmittedHash | {code_hash} | 10 minutes | Tracks repeated submissions of the same wrong code |
consecutiveForSlowDown | {IP} | 10 minutes | Progressive slowdown on repeated failures from same IP |
consecutiveForJti | {JTI} | 20 minutes | Tracks 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.
Cookie and response
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.
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:
| Reason | Internal flow |
|---|---|
MAGIC_LINK_MFA_CHECKS | Adaptive MFA |
PASSWORD_RESET | Password reset link |
PASSWORD_RESET_FLOW | Password reset verification |
EMAIL_MFA_FLOW | Email 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:
- Generates a unique JTI (random UUID + 64 bytes of
crypto.randomBytes) - Hashes the
randomchallenge with SHA-256 - Signs a temporary JWT with
purposeset to the reason andsubjectset to{reason}_{visitor} - Builds the verification URL using
pathForCustomFlow - Calls
generateMfaCodeto create and store the OTP - 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:
| Route | Method | Purpose |
|---|---|---|
/auth/verify-custom-mfa | GET | Validates the signed link token via customMfaFlowsVerification middleware |
/auth/verify-custom-mfa | POST | Accepts { "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'
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 deviceonSuccesscallback - 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.
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
| Route | Method | Purpose |
|---|---|---|
/auth/verify-mfa | GET | Validates the adaptive MFA magic link token |
/auth/verify-mfa | POST | Submits OTP code for adaptive MFA verification |
/custom/mfa/:reason | POST | Initiates a custom MFA flow (service-to-service, IP restricted) |
/auth/verify-custom-mfa | GET | Validates the custom MFA magic link token |
/auth/verify-custom-mfa | POST | Submits OTP code for custom MFA verification |
/update/email | POST | Submits new email + password + OTP for email change |
/auth/forgot-password | POST | Initiates a password reset email |
/auth/reset-password | GET | Validates the password reset magic link |
/auth/reset-password | POST | Submits the new password after link validation |
Configuration reference
OTP settings
crypto.randomInt with range 1_000_000 to 9_999_999.Bypass window
Rate limiters
'global_emails'. Prevents email-sending abuse at the system level.'user_{userId}'. Prevents a single user from triggering excessive emails.{IP}_{JTI} for verification attempts, or {IP}_{random}_{reason} for custom flow initiation. The most granular limiter.{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.