Magic Links
Magic links are short-lived, single-use JWTs embedded as URL query parameters in emails sent to the user. The IAM service uses them for four built-in flows: adaptive MFA (triggered by anomaly detection), password reset, email update, and custom MFA for app-defined sensitive actions. All four flows share the same signing, caching, and verification infrastructure, differing only in the purpose value carried in the JWT payload and the route that processes them.
Each magic link carries four query parameters: visitor, token (the signed JWT), random (a SHA-256-hashed challenge), and reason (the purpose string). Links expire after a configurable window (default 15 minutes), are cached in a server-side LRU store, and are consumed on use so that a verified link cannot be replayed even if the JWT itself has not expired yet.
Signing
tempJwtLink
tempJwtLink is the low-level function that creates a magic link JWT. It accepts a typed payload, signs it with HMAC-SHA512 using a dedicated secret key (magic_links.jwt_secret_key), and stores the resulting token in the magic links cache with valid: true.
import { tempJwtLink } from '@riavzon/auth'
const token = tempJwtLink<{ customField: string }>({
visitor: user.visitor_id,
subject: String(user.id),
purpose: 'PASSWORD_RESET',
jti: crypto.randomUUID(),
customField: 'value',
})
The JWT is signed with HS512 and includes standard claims (iss, sub, aud, jti, exp). The expiry is pulled from magic_links.expiresIn (default '15m').
Payload fields
| Field | Type | Description |
|---|---|---|
visitor | string | The visitor_id foreign key on the user record, used to bind the link to a specific device context |
subject | string | User ID that identifies the recipient |
purpose | string | Identifies the flow: 'PASSWORD_RESET', 'MAGIC_LINK_MFA_CHECKS', 'change_email', or a custom string |
jti | string | Unique JWT ID, used for replay prevention |
The type parameter lets you extend the payload with additional fields. Any extra properties are encoded into the JWT and available after verification.
JTI generation
The jti is constructed differently depending on the flow:
All flows use the same construction: crypto.randomUUID() concatenated with crypto.randomBytes(64).toString('hex'), producing a 164-character string (36-char UUID + 128 hex chars from 64 bytes).
Verification
verifyTempJwtLink
verifyTempJwtLink validates a magic link token through three checks:
- Cache lookup - the token must exist in the LRU cache and have
valid: true - JWT verification - signature, expiry, issuer, subject, audience, and
jtiare all validated against the magic link secret - Visitor match - the
visitorclaim in the JWT is compared against the visitor context of the current request
import { verifyTempJwtLink } from '@riavzon/auth'
const result = verifyTempJwtLink<{ customField?: string }>(rawToken)
if (!result.valid) {
// result includes the failure reason
return
}
const { subject, purpose, customField } = result.payload
If any check fails, the function returns { valid: false } with a descriptive error. The cache entry is not removed on a failed verification attempt, allowing the user to retry (subject to usage limits).
jwt_secret_key for magic links. Do not reuse the access-token or refresh-token secret. This limits the risk if one secret is compromised.Cache
The cache is a singleton LRU store keyed by the raw JWT string. Each entry stores a valid boolean and is automatically evicted after the configured TTL (magic_links.expiresInMs, default 15 minutes).
When a magic link is created, the token is inserted with valid: true. During verification, the cache is checked first. If the entry is missing (expired or evicted) or valid is false, verification fails immediately without attempting JWT decoding.
import { magicLinksCache } from '@riavzon/auth'
const cache = magicLinksCache()
cache.size // Current number of cached tokens
cache.delete(jti) // Force-expire a specific link (testing only)
Configure the cache size with magic_links.maxCacheEntries (default 500). The LRU eviction policy ensures the oldest unused entries are dropped first when the cache is full.
URL construction
All magic link emails build the destination URL from three config values:
magic_links.domain, the base URLmagic_links.paths.pathFor*, the path segment- Query parameters:
visitor,token,random,reason
The random parameter is crypto.randomBytes(128).toString('hex'), a 256-character hex string. The SHA-256 digest of that string is embedded in the signed JWT payload as randomHashed. On verification the middleware hashes the incoming random and compares it against randomHashed with crypto.timingSafeEqual to prevent timing attacks.
https://example.com/auth/bounce?visitor=42&token=eyJ...&random=a3f8...&reason=MAGIC_LINK_MFA_CHECKS
The bounce path is a thin redirect handler that forwards all query parameters to the application's verification page (default /auth/verify). This indirection lets you host the auth service on a different domain from your application. See auth-h3client for how the client handles the redirect.
Built-in flows
The service ships with three built-in flows. Each one uses the same signing, caching, and verification process described above.
Adaptive MFA
Adaptive MFA is triggered automatically when anomaly detection returns reqMFA: true. This happens inside protectRoute (during access-token verification) and during token rotation (inside rotateOnEveryUse). The user is not required to initiate this flow; the service detects the anomaly and sends the email automatically.
See MFA for the full adaptive MFA lifecycle, OTP code generation and verification, and the post-MFA bypass window.
Trigger points
| Caller | When | Purpose |
|---|---|---|
protectRoute | Every authenticated request | Anomaly detected during routine access-token check |
rotateOnEveryUse | Token rotation (POST /auth/user/refresh-session) | Anomaly detected during rotation |
Sending the link
sendTempMfaLink handles the full email delivery pipeline:
Rate limiting
Three rate limiters run in sequence before the email is sent:
| Limiter | Key | Points | Window | Block |
|---|---|---|---|---|
| Global | 'global_emails' | 800 | 24 h | 24 h |
| Per-user | 'user_{userId}' | 8 | 24 h | 12 h |
| Per-IP | {ip} | 5 | 24 h | 4 h |
If any limiter rejects the request, the function returns early and the email is not sent.
JTI and random generation
The function generates a JTI (crypto.randomUUID() + crypto.randomBytes(64)) and a random challenge (crypto.randomBytes(128)), then hashes the random with SHA-256.
JWT signing
tempJwtLink is called with purpose: 'MAGIC_LINK_MFA_CHECKS' and the generated JTI.
OTP code generation
generateMfaCode creates a 7-digit numeric code, hashes it with SHA-256, and stores the hash in the database. See MFA - OTP generation for the full details.
Email delivery
The link URL and OTP code are rendered into an EJS template and sent via the Resend SDK. The email includes device metadata (browser, OS, location) so the user can recognize unexpected requests.
Routes
| Method | Path | Description |
|---|---|---|
GET | /auth/verify-mfa | Validates the signed link token from the email |
POST | /auth/verify-mfa | Submits the 7-digit OTP code and completes verification |
Password reset
The password reset flow is the only built-in flow that does not generate an OTP code. The user proves identity by clicking the email link alone, then submits a new password directly.
User requests a reset
The user submits their email to POST /auth/forgot-password. The controller looks up the account and rejects OAuth-only users (those with password_hash = 'no_password'). A signed link is generated with purpose: 'PASSWORD_RESET' and sent via email.
The controller enforces a minimum 3-second response time regardless of whether the email exists. Combined with always returning 200, this prevents timing-based user enumeration.
User clicks the link
The email link targets the bounce handler, which redirects to the application's reset-password page. The page calls GET /auth/reset-password with the link's query parameters. The linkPasswordVerification middleware validates the token, visitor match, and random challenge.
User submits a new password
POST /auth/reset-password receives { password, confirmedPassword }, verifies the magic link token again, hashes the new password with Argon2id, and updates the user record. The link is consumed and cannot be reused.
Routes
| Method | Path | Description |
|---|---|---|
POST | /auth/forgot-password | Initiates the reset by sending the email |
GET | /auth/reset-password | Validates the magic link token |
POST | /auth/reset-password | Submits the new password |
Email update
The email update flow uses the custom MFA process with the reserved purpose 'change_email'. It requires the user to be authenticated, pass a custom MFA challenge, and confirm their current password before the email is changed.
User initiates the change
The authenticated user calls POST /update/email. The controller generates a random challenge and calls generateCustomMfaFlow with reason: 'change_email'. A magic link and OTP code are sent to the user's current email address.
User clicks the link
The email link targets the bounce handler, which redirects to the application's email-update page. The page calls GET /auth/verify-custom-mfa with the link's query parameters. The customMfaFlowsVerification middleware validates the token.
User submits the new email and code
POST /update/email receives { code, email, newEmail, password }. The controller verifies that the magic link purpose is 'change_email', confirms the current password with Argon2, and delegates to verifyMfaCode with revokeAllTokensOnSuccess: true. On success, the email is updated and all existing sessions are revoked, forcing the user to log in again with the new email.
Routes
| Method | Path | Description |
|---|---|---|
POST | /custom/mfa/change_email | Initiates the flow (sends magic link + OTP) |
GET | /auth/verify-custom-mfa | Validates the magic link token |
POST | /update/email | Submits the new email, OTP code, and current password |
Custom MFA flows
Custom MFA flows let you protect any sensitive action behind a second-factor email check. The user must be authenticated to initiate a custom flow. You define the reason string, and the service handles link generation, OTP creation, email delivery, and verification.
Reserved reasons
The following reason strings are reserved and cannot be used for custom flows:
| Reserved string | Used by |
|---|---|
MAGIC_LINK_MFA_CHECKS | Adaptive MFA |
PASSWORD_RESET | Password reset |
PASSWORD_RESET_FLOW | Internal |
EMAIL_MFA_FLOW | Internal |
Any other string is valid as a custom reason (e.g. 'payment', 'account-delete', 'transfer-funds').
Initiating a custom flow
POST /custom/mfa/:reason initiates the flow. The controller requires the request to originate from a trusted IP (either the client IP or the proxy's ipToTrust). The random query parameter must be a high-entropy string between 254 and 500 characters.
import { generateCustomMfaFlow } from '@riavzon/auth'
await generateCustomMfaFlow(random, 'payment', user, sessionToken, ip, res, {
device: 'Chrome on Windows',
browser: 'Chrome 125',
location: 'Berlin, DE',
})
The function generates a JTI, signs a temporary link with purpose set to the provided reason, generates a 7-digit OTP code, and sends the email. The same triple rate limiting described in Adaptive MFA applies.
Verifying a custom flow
After the user clicks the email link:
| Method | Path | Description |
|---|---|---|
GET | /auth/verify-custom-mfa | Validates the link token via customMfaFlowsVerification middleware |
POST | /auth/verify-custom-mfa | Submits the OTP code and completes verification |
On successful POST verification, verifyMfaCode runs with returnMetaData: true, making limited user metadata available to the calling controller via the response. See MFA - OTP verification for the full verification sequence.
Verification middleware
Three middleware functions handle magic link verification on the server side. Each one follows the same pattern:
- Zod validation of the query parameters (
visitor,token,random,reason) - Rate limiting keyed on the hashed token
- JWT verification via
verifyTempJwtLink - Visitor match - the
visitorclaim is compared with the current request context - Timing-safe hash comparison of the
randomparameter against the stored challenge
| Middleware | Purpose | Used by |
|---|---|---|
linkMfaVerification | Adaptive MFA links | GET/POST /auth/verify-mfa |
linkPasswordVerification | Password reset links | GET/POST /auth/reset-password |
customMfaFlowsVerification | Custom MFA and email update links | GET/POST /auth/verify-custom-mfa |
Usage limits
Each middleware tracks how many times a link has been used for GET and POST requests separately. If the limit is exceeded, the link is invalidated regardless of its expiry.
| Request type | Default limit | Config path |
|---|---|---|
| GET | 5 | magic_links.thresholds.*.allowedPerSuccessfulGet |
| POST | 3 | magic_links.thresholds.*.allowedPerSuccessfulPost |
The threshold config keys correspond to each flow:
magic_links.thresholds.adaptiveMfa- adaptive MFA linksmagic_links.thresholds.linkPasswordVerification- password reset linksmagic_links.thresholds.customMfaFlowsAndEmailChanges- custom MFA and email update links
Examples
Mounting the router
The magicLinks export is a pre-built router with all nine routes already wired. Mount it on your app with:
import express from 'express'
import { magicLinks } from '@riavzon/auth'
const app = express()
app.use(magicLinks)
This registers every route listed in Routes reference, including the full middleware chains for rate limiting, Zod validation, link verification, and bot detection. No additional setup is required.
bootstrapApp() from @riavzon/auth/service, the magic links router is mounted automatically and you do not need to call app.use(magicLinks).Triggering adaptive MFA from a custom handler
sendTempMfaLink is the function that protectRoute calls internally when anomaly detection returns reqMFA: true. You can call it directly from any handler to force an MFA challenge:
import { sendTempMfaLink } from '@riavzon/auth'
async function myProtectedHandler(req, res) {
const result = await sendTempMfaLink(
{ userId: Number(req.user.userId), visitor: req.user.visitor_id },
req.cookies.session, // raw refresh token
req.ip!,
res,
{
device: 'Chrome on macOS',
browser: 'Chrome 125',
location: 'Paris, FR',
}
)
if (result === 'rate_limited') {
return res.status(429).json({ error: 'Too many MFA requests. Try again later.' })
}
// The email has been sent. Respond with 202 so the client knows to prompt for MFA.
return res.status(202).json({ message: 'Please check your email for a verification link.' })
}
The user parameter expects { userId: number, visitor: string }. The meta parameter expects { device: string, browser: string, location: string }, which is rendered into the email template so the user can recognize the request.
The function applies three rate limiters (global, per-user, per-IP), generates the JTI and random challenge, signs the JWT, creates the 7-digit OTP code, and sends the email. It returns true on success or 'rate_limited' if any limiter rejects.
Password reset
sendTempPasswordResetLink handles the full password-reset email pipeline. The POST /auth/forgot-password route, calls this internally, but you can use it from a custom controller:
import { sendTempPasswordResetLink } from '@riavzon/auth'
async function customForgotPassword(req, res) {
const startTime = Date.now()
const { valid, error } = await sendTempPasswordResetLink(req.body.email)
// Enforce a minimum 3-second response time to prevent timing-based enumeration.
const elapsed = Date.now() - startTime
if (elapsed < 3000) {
await new Promise(resolve => setTimeout(resolve, 3000 - elapsed))
}
// Always return success regardless of whether the email exists.
return res.status(200).json({
ok: true,
message: 'If that email is registered, a reset link has been sent.',
})
}
The function looks up the user, rejects OAuth accounts (no password to reset), generates a signed link with purpose: 'PASSWORD_RESET', and sends the email. It does not generate an OTP code.
Building a custom MFA-protected action
To protect a sensitive action behind a custom MFA flow, wire three routes: one to initiate the flow, one to verify the magic link (GET), and one to verify the OTP code and execute the action (POST).
import express from 'express'
import crypto from 'crypto'
import {
validateContentType,
requireAccessToken,
requireRefreshToken,
getFingerPrint,
protectRoute,
customMfaFlowsVerification,
generateCustomMfaFlow,
verifyCustomMfa,
} from '@riavzon/auth'
import { detectBots } from '@riavzon/bot-detector'
const router = express.Router()
// Step 1: Initiate the custom MFA flow
router.post('/danger-zone/initiate',
validateContentType('application/json'),
requireAccessToken,
requireRefreshToken,
getFingerPrint,
protectRoute,
express.json({ limit: '1kb' }),
async (req, res) => {
const random = crypto.randomBytes(128).toString('hex')
const result = await generateCustomMfaFlow(
random,
'account-delete', // your custom reason string
{ userId: Number(req.user!.userId), visitor: req.user!.visitor_id },
req.cookies.session, // raw refresh token
req.ip!,
res,
{
device: req.headers['user-agent'] ?? 'Unknown Device',
browser: 'Unknown Browser',
location: 'Unknown Location',
}
)
if (!result.ok && result.data === 'rate_limited') return
if (!result.ok && result.data === 'exists') {
return res.status(400).json({ error: 'This reason is already reserved.' })
}
return res.status(200).json(result)
}
)
// Step 2: Verify the magic link (GET)
// customMfaFlowsVerification sends the response on GET automatically.
router.get('/danger-zone/verify',
requireAccessToken,
requireRefreshToken,
getFingerPrint,
protectRoute,
customMfaFlowsVerification
)
// Step 3: Verify the OTP code and execute the action (POST)
router.post('/danger-zone/verify',
validateContentType('application/json'),
requireAccessToken,
requireRefreshToken,
getFingerPrint,
protectRoute,
express.json({ limit: '1kb' }),
customMfaFlowsVerification,
detectBots,
async (req, res) => {
// MFA verified. req.link is populated with the decoded payload.
const { purpose, subject } = req.link
// Execute the protected action
await deleteUserAccount(Number(req.user!.userId))
return res.json({ ok: true, message: 'Account deleted.' })
}
)
After customMfaFlowsVerification passes on a POST request, req.link is populated with the decoded JWT payload:
req.link = {
visitor: string, // visitor_id
subject: string, // `${reason}_${visitor_id}`
purpose: string, // the reason string ('account-delete' in this example)
jti?: string, // unique link identifier (optional)
}
Manual signing and verification
For full control over the JWT lifecycle, use signNewTempLink and verifyTempLink directly. These are the low-level primitives that all built-in flows call internally.
import { signNewTempLink, verifyTempLink, magicLinksCache } from '@riavzon/auth'
import crypto from 'crypto'
// --- Sign ---
const token = signNewTempLink<{ transferId: string }>({
visitor: user.visitor_id, // string
subject: String(user.userId), // string
purpose: 'wire-transfer',
jti: crypto.randomUUID(),
transferId: 'txn_abc123',
})
// Build the email URL
const url = new URL('/auth/bounce', config.magic_links.domain)
url.searchParams.set('visitor', user.visitor_id)
url.searchParams.set('token', token)
url.searchParams.set('random', crypto.createHash('sha256')
.update(crypto.randomBytes(128).toString('hex'))
.digest('hex'))
url.searchParams.set('reason', 'wire-transfer')
// Send url.toString() via your email provider
// --- Verify ---
const result = verifyTempLink<{ transferId: string }>(token)
if (!result.valid) {
console.error('Link invalid:', result.errorType)
return
}
const { subject, purpose, transferId } = result.payload!
// purpose === 'wire-transfer', transferId === 'txn_abc123'
// --- Invalidate ---
const cache = magicLinksCache()
cache.delete(token) // Mark the link as consumed
sendTempMfaLink, generateCustomMfaFlow, or the pre-built magicLinks router unless you have a specific reason to sign tokens directly.Email delivery
Magic link emails are sent via the Resend SDK. The service uses EJS templates to render two email types:
| Template | Used by | Contains |
|---|---|---|
| OTP template | Adaptive MFA, custom MFA, email update | Magic link button, 7-digit OTP code, device/browser/location metadata |
| Password reset template | Password reset | Magic link button, device/browser/location metadata (no OTP code) |
Both templates include the website name, privacy policy link, contact page link, and email images (OTP banner). These values are configured in magic_links.notificationEmail and magic_links.emailImages.
Configuration reference
All options live under the magic_links key in the auth service config. Overridable via config.magic_links.*.
Core
'15m' - JWT lifetime in ms-compatible string format.900_000 - Same lifetime in milliseconds, used for the LRU cache TTL. Must match expiresIn.'https://example.com'). Prepended to every path.500 - Maximum number of active magic link tokens held in the LRU cache.Paths
Path segments appended to domain when building email link URLs. All default to '/auth/bounce'.
'/auth/bounce' - Email link target for password reset flows.'/auth/bounce' - Email link target for adaptive MFA flows.'/auth/bounce' - Email link target for custom MFA flows and email updates.Thresholds
Usage limits per link. Each middleware tracks GET and POST uses separately. When exceeded, the link is invalidated regardless of expiry.
5 - Maximum GET verifications allowed per adaptive MFA link.3 - Maximum POST submissions allowed per adaptive MFA link.5 - Maximum GET verifications allowed per password reset link.3 - Maximum POST submissions allowed per password reset link.5 - Maximum GET verifications allowed per custom MFA or email update link.3 - Maximum POST submissions allowed per custom MFA or email update link.Email templates
Routes reference
All routes handled by the magic links router on the auth server:
| Method | Path | Middleware | Description |
|---|---|---|---|
GET | /auth/verify-mfa | linkMfaVerification | Validate adaptive MFA link |
POST | /auth/verify-mfa | linkMfaVerification | Submit OTP code for adaptive MFA |
POST | /custom/mfa/:reason | Auth required, IP restriction | Initiate a custom MFA flow |
GET | /auth/verify-custom-mfa | customMfaFlowsVerification | Validate custom MFA link |
POST | /auth/verify-custom-mfa | customMfaFlowsVerification | Submit OTP code for custom MFA |
POST | /update/email | customMfaFlowsVerification | Submit new email with OTP + password |
POST | /auth/forgot-password | Rate limiting | Initiate password reset |
GET | /auth/reset-password | linkPasswordVerification | Validate password reset link |
POST | /auth/reset-password | linkPasswordVerification | Submit new password |
Rate limiter reference
Magic link flows apply multiple layers of rate limiting. All values below are defaults, overridable via config.rate_limiters.*. See Rate Limiting for the guard() architecture and the consecutive-violation escalation system.
Email sending (MFA and custom flows)
Applied by sendTempMfaLink and generateCustomMfaFlow before sending any email.
| Limiter | Key | Points | Window | Block | Config path |
|---|---|---|---|---|---|
| Global | 'global_emails' | 800 | 24 h | 24 h | rate_limiters.emailMfaLimiters.globalEmailLimiter |
| Per-user | 'user_{userId}' | 8 | 24 h | 12 h | rate_limiters.emailMfaLimiters.userIdLimiter |
| Per-IP | {ip} | 5 | 24 h | 4 h | rate_limiters.emailMfaLimiters.ipLimiter |
| Burst | {ip}_{random}_{reason} | 1 | 1 s | 30 min | rate_limiters.emailMfaLimiters.unionLimiter |
| Slow | {ip}_{random}_{reason} | 4 | 30 min | 15 min | rate_limiters.emailMfaLimiters.unionLimiter |
Password reset initiation
Applied by the initPasswordReset controller on POST /auth/forgot-password.
| Limiter | Key | Points | Window | Block | Config path |
|---|---|---|---|---|---|
| Global | 'global_emails' | 800 | 24 h | 24 h | rate_limiters.emailMfaLimiters.globalEmailLimiter |
| Per-IP | {ip} | 5 | 24 h | 4 h | rate_limiters.initPasswordResetLimiters.ipLimiter |
| Per-email | {email} | 5 | 24 h | 4 h | rate_limiters.initPasswordResetLimiters.emailLimiter |
| Burst | {ip}_{email} | 1 | 1 s | 30 min | rate_limiters.initPasswordResetLimiters.unionLimiter |
| Slow | {ip}_{email} | 4 | 30 min | 15 min | rate_limiters.initPasswordResetLimiters.unionLimiter |
Link verification (all three middleware functions)
Applied by linkMfaVerification, linkPasswordVerification, and customMfaFlowsVerification on every GET and POST.
| Limiter | Key | Points | Window | Block | Config path |
|---|---|---|---|---|---|
| Burst | {ip} | 2 | 1 s | 15 min | rate_limiters.linkVerificationLimiter.unionLimiter |
| Slow | {ip} | 30 | 30 min | 30 min | rate_limiters.linkVerificationLimiter.unionLimiter |
| JTI single-use | {jti} | 0 | 0 s | 20 min | Internal (not configurable) |
The JTI limiter is a block-only limiter. After a link is consumed, its JTI is blocked for 20 minutes to prevent replay.
OTP code submission
Applied by verifyMfaCode when processing a POST with a 7-digit code (adaptive MFA, custom MFA, and email update).
| Limiter | Key | Points | Window | Block | Config path |
|---|---|---|---|---|---|
| Burst | {ip} | 1 | 1 s | 30 min | rate_limiters.tempPostRoutesLimiter.unionLimiter |
| Slow | {ip} | 5 | 10 min | 10 min | rate_limiters.tempPostRoutesLimiter.unionLimiter |
| Per-JTI | {jti} | 1 | 1 s | 30 min | rate_limiters.tempPostRoutesLimiter.unionLimiter |
| Per-IP (code hash) | {codeHash} | 6 | 10 min | 10 min | rate_limiters.tempPostRoutesLimiter.ipLimit |
After successful verification, the submitted code hash is blocked for 10 minutes and the JTI is blocked for 20 minutes. This prevents reuse of the same code or link.