Magic Links

Signed temporary JWT links for adaptive MFA, password reset, email update, and custom flows -- how they are created, verified, rate-limited, and consumed in the IAM service.

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

FieldTypeDescription
visitorstringThe visitor_id foreign key on the user record, used to bind the link to a specific device context
subjectstringUser ID that identifies the recipient
purposestringIdentifies the flow: 'PASSWORD_RESET', 'MAGIC_LINK_MFA_CHECKS', 'change_email', or a custom string
jtistringUnique 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 validates a magic link token through three checks:

  1. Cache lookup - the token must exist in the LRU cache and have valid: true
  2. JWT verification - signature, expiry, issuer, subject, audience, and jti are all validated against the magic link secret
  3. Visitor match - the visitor claim 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).

Use a separate 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 URL
  • magic_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

CallerWhenPurpose
protectRouteEvery authenticated requestAnomaly detected during routine access-token check
rotateOnEveryUseToken rotation (POST /auth/user/refresh-session)Anomaly detected during rotation

sendTempMfaLink handles the full email delivery pipeline:

Rate limiting

Three rate limiters run in sequence before the email is sent:

LimiterKeyPointsWindowBlock
Global'global_emails'80024 h24 h
Per-user'user_{userId}'824 h12 h
Per-IP{ip}524 h4 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

MethodPathDescription
GET/auth/verify-mfaValidates the signed link token from the email
POST/auth/verify-mfaSubmits 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.

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

MethodPathDescription
POST/auth/forgot-passwordInitiates the reset by sending the email
GET/auth/reset-passwordValidates the magic link token
POST/auth/reset-passwordSubmits 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.

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

MethodPathDescription
POST/custom/mfa/change_emailInitiates the flow (sends magic link + OTP)
GET/auth/verify-custom-mfaValidates the magic link token
POST/update/emailSubmits 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 stringUsed by
MAGIC_LINK_MFA_CHECKSAdaptive MFA
PASSWORD_RESETPassword reset
PASSWORD_RESET_FLOWInternal
EMAIL_MFA_FLOWInternal

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:

MethodPathDescription
GET/auth/verify-custom-mfaValidates the link token via customMfaFlowsVerification middleware
POST/auth/verify-custom-mfaSubmits 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:

  1. Zod validation of the query parameters (visitor, token, random, reason)
  2. Rate limiting keyed on the hashed token
  3. JWT verification via verifyTempJwtLink
  4. Visitor match - the visitor claim is compared with the current request context
  5. Timing-safe hash comparison of the random parameter against the stored challenge
MiddlewarePurposeUsed by
linkMfaVerificationAdaptive MFA linksGET/POST /auth/verify-mfa
linkPasswordVerificationPassword reset linksGET/POST /auth/reset-password
customMfaFlowsVerificationCustom MFA and email update linksGET/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 typeDefault limitConfig path
GET5magic_links.thresholds.*.allowedPerSuccessfulGet
POST3magic_links.thresholds.*.allowedPerSuccessfulPost

The threshold config keys correspond to each flow:

  • magic_links.thresholds.adaptiveMfa - adaptive MFA links
  • magic_links.thresholds.linkPasswordVerification - password reset links
  • magic_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.

If you use 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
When signing manually, you are responsible for rate limiting, visitor matching, and link consumption. The built-in flows and middleware handle all of this automatically. Prefer using sendTempMfaLink, generateCustomMfaFlow, or the pre-built magicLinks router unless you have a specific reason to sign tokens directly.
The auth-h3client package provides a full client-side implementation for all magic link flows. It handles bounce redirection, route registration, composable state management, and 202 MFA response propagation. See the auth-h3client documentation for usage details.

Email delivery

Magic link emails are sent via the Resend SDK. The service uses EJS templates to render two email types:

TemplateUsed byContains
OTP templateAdaptive MFA, custom MFA, email updateMagic link button, 7-digit OTP code, device/browser/location metadata
Password reset templatePassword resetMagic 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

jwt_secret_key
string required
The HMAC-SHA512 signing key for magic link JWTs. Must be separate from the access-token and refresh-token secrets.
expiresIn
string
Default '15m' - JWT lifetime in ms-compatible string format.
expiresInMs
number
Default 900_000 - Same lifetime in milliseconds, used for the LRU cache TTL. Must match expiresIn.
domain
string required
Base URL for link construction (e.g. 'https://example.com'). Prepended to every path.
maxCacheEntries
number
Default 500 - Maximum number of active magic link tokens held in the LRU cache.
linkToResetPasswordPage
string
The URL of your app's "reset your password" page, included in the password-reset email body.

Paths

Path segments appended to domain when building email link URLs. All default to '/auth/bounce'.

paths.pathForPasswordResetLink
string
Default '/auth/bounce' - Email link target for password reset flows.
paths.pathForAdaptiveMfaLink
string
Default '/auth/bounce' - Email link target for adaptive MFA flows.
paths.pathForCustomFlow
string
Default '/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.

thresholds.adaptiveMfa.allowedPerSuccessfulGet
number
Default 5 - Maximum GET verifications allowed per adaptive MFA link.
thresholds.adaptiveMfa.allowedPerSuccessfulPost
number
Default 3 - Maximum POST submissions allowed per adaptive MFA link.
thresholds.linkPasswordVerification.allowedPerSuccessfulGet
number
Default 5 - Maximum GET verifications allowed per password reset link.
thresholds.linkPasswordVerification.allowedPerSuccessfulPost
number
Default 3 - Maximum POST submissions allowed per password reset link.
thresholds.customMfaFlowsAndEmailChanges.allowedPerSuccessfulGet
number
Default 5 - Maximum GET verifications allowed per custom MFA or email update link.
thresholds.customMfaFlowsAndEmailChanges.allowedPerSuccessfulPost
number
Default 3 - Maximum POST submissions allowed per custom MFA or email update link.

Email templates

emailImages.otpBanner
string
URL of the banner image displayed at the top of OTP emails.
notificationEmail.websiteName
string
The display name of your website, shown in all magic link emails.
notificationEmail.privacyPolicyLink
string
URL to your privacy policy page, linked in the email footer.
notificationEmail.contactPageLink
string
URL to your contact page, linked in the email footer.
notificationEmail.changePasswordPageLink
string
URL to your change-password page, linked in notification emails.
notificationEmail.loginPageLink
string
URL to your login page, linked in notification emails.

Routes reference

All routes handled by the magic links router on the auth server:

MethodPathMiddlewareDescription
GET/auth/verify-mfalinkMfaVerificationValidate adaptive MFA link
POST/auth/verify-mfalinkMfaVerificationSubmit OTP code for adaptive MFA
POST/custom/mfa/:reasonAuth required, IP restrictionInitiate a custom MFA flow
GET/auth/verify-custom-mfacustomMfaFlowsVerificationValidate custom MFA link
POST/auth/verify-custom-mfacustomMfaFlowsVerificationSubmit OTP code for custom MFA
POST/update/emailcustomMfaFlowsVerificationSubmit new email with OTP + password
POST/auth/forgot-passwordRate limitingInitiate password reset
GET/auth/reset-passwordlinkPasswordVerificationValidate password reset link
POST/auth/reset-passwordlinkPasswordVerificationSubmit 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.

LimiterKeyPointsWindowBlockConfig path
Global'global_emails'80024 h24 hrate_limiters.emailMfaLimiters.globalEmailLimiter
Per-user'user_{userId}'824 h12 hrate_limiters.emailMfaLimiters.userIdLimiter
Per-IP{ip}524 h4 hrate_limiters.emailMfaLimiters.ipLimiter
Burst{ip}_{random}_{reason}11 s30 minrate_limiters.emailMfaLimiters.unionLimiter
Slow{ip}_{random}_{reason}430 min15 minrate_limiters.emailMfaLimiters.unionLimiter

Password reset initiation

Applied by the initPasswordReset controller on POST /auth/forgot-password.

LimiterKeyPointsWindowBlockConfig path
Global'global_emails'80024 h24 hrate_limiters.emailMfaLimiters.globalEmailLimiter
Per-IP{ip}524 h4 hrate_limiters.initPasswordResetLimiters.ipLimiter
Per-email{email}524 h4 hrate_limiters.initPasswordResetLimiters.emailLimiter
Burst{ip}_{email}11 s30 minrate_limiters.initPasswordResetLimiters.unionLimiter
Slow{ip}_{email}430 min15 minrate_limiters.initPasswordResetLimiters.unionLimiter

Applied by linkMfaVerification, linkPasswordVerification, and customMfaFlowsVerification on every GET and POST.

LimiterKeyPointsWindowBlockConfig path
Burst{ip}21 s15 minrate_limiters.linkVerificationLimiter.unionLimiter
Slow{ip}3030 min30 minrate_limiters.linkVerificationLimiter.unionLimiter
JTI single-use{jti}00 s20 minInternal (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).

LimiterKeyPointsWindowBlockConfig path
Burst{ip}11 s30 minrate_limiters.tempPostRoutesLimiter.unionLimiter
Slow{ip}510 min10 minrate_limiters.tempPostRoutesLimiter.unionLimiter
Per-JTI{jti}11 s30 minrate_limiters.tempPostRoutesLimiter.unionLimiter
Per-IP (code hash){codeHash}610 min10 minrate_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.

Logo