Password Reset

The full password reset lifecycle in the IAM service, from initiation via POST /auth/forgot-password through link verification and new password submission at POST /auth/reset-password, including rate limiting, breach checking, and post-reset notifications.

The password reset flow is a two-phase operation. The first phase initiates the flow by sending a signed magic link to the user's email. The second phase verifies the link, validates and hashes the new password, revokes all active sessions, and sends a security notification. The flow does not apply to OAuth-only accounts, which have no password to reset.


Initiation: POST /auth/forgot-password

The initiation endpoint accepts a JSON body containing the user's email address, applies several layers of rate limiting, and always returns 200 regardless of whether the email was found. This prevents email enumeration.

Request

POST /auth/forgot-password
Content-Type: application/json

{ "email": "[email protected]" }

Rate limiting

Four limiters protect the initiation endpoint before the email is looked up:

LimiterKeyDefault pointsDefault durationDefault blockPurpose
globalEmailLimiter'global_emails'shared system budgetCaps total outgoing emails system-wide
ipLimiter${IP}524hr4hrCaps reset requests per IP per day
emailLimiter${email}524hr4hrCaps reset requests per email per day
uniLimiter (union)${IP}_${email}burst + slow1s / 30min30minBlocks rapid or sustained composite attempts on failure

The globalEmailLimiter and ipLimiter run before schema validation. The emailLimiter runs after validation. The uniLimiter is only consumed when the email is not found, adding a cost to probing non-existent addresses.

Initiation flow

Schema validation

The request body is validated with a Zod schema that checks the email format. Invalid or XSS-flagged input returns 400 or 403 before any database query runs.

User lookup

sendTempPasswordResetLink queries the users table for a matching email. If no user is found, the uniLimiter is consumed for the composite key ${IP}_${email} and the function returns without sending an email.

OAuth-only rejection

If the user's password_hash is 'no_password', the user authenticated exclusively through OAuth and has no password to reset. The function returns without sending an email.

A unique JTI is generated using crypto.randomUUID() + crypto.randomBytes(64).toString('hex'), producing a 164-character string. A random challenge is generated with crypto.randomBytes(128).toString('hex'), then SHA-256 hashed.

A temporary JWT is signed with:

FieldValue
purposePASSWORD_RESET
subjectPASSWORD_RESET_{visitor_id}
visitorThe user's visitor_id
randomHashedSHA-256 hash of the random challenge
jtiThe 164-character JTI

The reset URL is built from magic_links.paths.pathForPasswordResetLink and magic_links.domain, with visitor, token, random, and reason appended as query parameters. The link is sent to the user's email via resetPasswordEmail.

Minimum response time

The controller enforces a 3-second minimum response time using Date.now() at the start and waitSomeTime() in the finally block. This prevents timing-based enumeration regardless of how quickly the database query completes.

Response

The endpoint always returns 200 after the minimum delay, whether or not an email was sent:

{ "success": true, "details": "A link to restart your password was sent to your email!" }
If any rate limiter blocks the request before the finally block, the response is the limiter's error (typically 429) rather than the generic 200. The 3-second delay only applies to the success path.

When the user clicks the link in their email, the GET request validates the signed token without consuming it. The link can be visited up to allowedPerSuccessfulGet times (default: 5) before it is considered expired.

Query parameters

ParameterDescription
tokenThe signed temporary JWT
randomThe raw random challenge (256 hex characters from 128 bytes)
visitorThe user's visitor_id
reasonMust be PASSWORD_RESET

Verification steps

Schema validation

The query string is validated with a Zod schema (buildInMfaFlows). Malformed or XSS-flagged values return 400 or 403.

IP rate limit

A per-IP limiter (consecutiveForIpPassword) is checked before JWT verification. Repeated failures from the same IP are blocked progressively.

JWT verification

verifyTempJwtLink verifies the signed token. An invalid or expired token returns 400.

Visitor match

The visitor query parameter must match payload.visitor. A mismatch returns 400.

Payload integrity

The following checks run against the decoded payload:

  • payload.purpose must equal reason (PASSWORD_RESET)
  • payload.subject must equal PASSWORD_RESET_{visitor}
  • The SHA-256 hash of random must match payload.randomHashed via crypto.timingSafeEqual

Any mismatch returns 401.

JTI replay check

The usedJtiLimiter is checked. If the JTI has been blocked (consumed on a prior POST), the request returns 400 with 'Link is not valid or expired'.

GET count enforcement

An in-memory counter tracks how many times this JTI has been accessed via GET. Exceeding allowedPerSuccessfulGet (default 5) returns 400.

The GET handler returns 200 without a body if all checks pass. The client uses this to confirm the link is valid before rendering the password reset form.


Password submission: POST /auth/reset-password

The POST to the same route runs the same linkPasswordVerification middleware as the GET, then delegates to verifyNewPassword to validate and apply the new password.

Request

POST /auth/reset-password?token=<jwt>&random=<hex>&visitor=<id>&reason=PASSWORD_RESET
Content-Type: application/json

{
  "password": "new-password",
  "confirmedPassword": "new-password"
}

Rate limiting

Three limiters protect the POST submission inside verifyNewPassword:

LimiterKeyDefault pointsDefault durationDefault blockPurpose
ipLimit${JTI}610min10minCaps attempts per verification session
ipLimit${IP}610min10minCaps attempts per IP
uniLimiter (union)${IP}_${visitor_id}burst + slow1s / 10min30min / 10minBlocks rapid or sustained composite attempts

Each failed attempt increments the corresponding consecutive cache. Exceeding the threshold blocks further attempts for the cache duration (10–20 minutes).

Submission flow

Content type check

The request must have Content-Type: application/json. Any other content type returns 400.

req.link.purpose must be PASSWORD_RESET and req.link.subject must be PASSWORD_RESET_{visitor}. A mismatch returns 400.

Rate limit checks

The three limiters above are checked in order: JTI limiter, IP limiter, then composite union limiter. Any failure returns 429.

Schema validation

The request body is validated against the passwords Zod schema, which requires both password and confirmedPassword. Invalid input returns 400.

Password match

password and confirmedPassword are compared. A mismatch returns 400 with { "error": "Password doesn't match", "banned": false }.

Breach check

The password is checked against the HaveIBeenPwned API via isPwned. If the password appears in a known data breach, the request is rejected with 400:

{
  "ok": false,
  "error": "Our system identified this password in X data breaches. Please choose a different password."
}

Password hashing

The new password is hashed with Argon2id via hashPassword.

User lookup

The user record is fetched from the database using the visitor_id from the link token, locked with FOR UPDATE inside a transaction.

Password update

UPDATE users
SET password_hash = ?
WHERE id = ? AND visitor_id = ?
LIMIT 1

If affectedRows is not exactly 1, the transaction rolls back and 400 is returned.

Session revocation

All active refresh tokens for the user are invalidated:

UPDATE refresh_tokens
SET valid = 0
WHERE user_id = ? AND valid = 1

JTI block

The JTI is blocked in usedJtiLimiter for 20 minutes, preventing link reuse.

Notification email

After a successful commit, a security notification email is sent to the user's address with the subject "Security Alert: Password Reset Successful". The email includes a link to the login page configured at magic_links.notificationEmail.loginPageLink.

Rate limiter cleanup

All consecutive caches and the union limiter for the composite key are reset on success.

Response

{ "success": true }

Security properties

PropertyValue
Enumeration protectionAlways returns 200 on the initiation endpoint after a 3-second minimum delay
OAuth accountsUsers with password_hash = 'no_password' are silently skipped
Link integrityRandom challenge verified with crypto.timingSafeEqual; purpose and subject claims validated
Link reuseJTI blocked for 20 minutes after successful POST
Link expiryDetermined by the temporary JWT TTL configured in magic_links
GET limitDefault 5 previews per link before it is considered expired
Breach checkHaveIBeenPwned API called before storing the new hash
Session revocationAll active refresh tokens invalidated on success
NotificationSecurity email sent to confirm the change; alerts the user if unauthorized

Configuration reference

magic_links.paths.pathForPasswordResetLink
string required
The path component of the password reset URL. Combined with magic_links.domain to build the full reset link sent in the email.
magic_links.notificationEmail.loginPageLink
string required
URL of the login page, included in the post-reset security notification email as the call-to-action link.
magic_links.thresholds.linkPasswordVerification.allowedPerSuccessfulGet
number
Number of times a password reset link may be visited via GET before it is considered expired.
magic_links.thresholds.linkPasswordVerification.allowedPerSuccessfulPost
number
Number of POST submission attempts allowed per successful link verification session.

Initiation rate limiters

rate_limiters.initPasswordResetLimiters.unionLimiters.limit
object
Burst limiter for the ${IP}_${email} composite key. Defaults: 1 point, 1s duration, 30min block. Consumed only on failed email lookups.
rate_limiters.initPasswordResetLimiters.unionLimiters.longLimiter
object
Slow limiter for the ${IP}_${email} composite key. Defaults: 4 points, 30min duration, 15min block.
rate_limiters.initPasswordResetLimiters.ipLimiter
object
Per-IP limiter. Defaults: 5 points, 24hr duration, 4hr block.
rate_limiters.initPasswordResetLimiters.emailLimiter
object
Per-email limiter. Defaults: 5 points, 24hr duration, 4hr block.

Verification rate limiters

These use the tempPostRoutesLimiters limiter group shared with other temporary-link POST routes:

rate_limiters.tempPostRoutesLimiters.unionLimiters.limit
object
Burst limiter keyed by ${IP}_${visitor_id}. Defaults: 1 point, 1s duration, 30min block.
rate_limiters.tempPostRoutesLimiters.unionLimiters.slowLimit
object
Slow limiter keyed by ${IP}_${visitor_id}. Defaults: 5 points, 10min duration, 10min block.
rate_limiters.tempPostRoutesLimiters.ipLimit
object
Per-IP and per-JTI limiter. Defaults: 6 points, 10min duration, 10min block.
Logo