Password Reset
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:
| Limiter | Key | Default points | Default duration | Default block | Purpose |
|---|---|---|---|---|---|
globalEmailLimiter | 'global_emails' | shared system budget | — | — | Caps total outgoing emails system-wide |
ipLimiter | ${IP} | 5 | 24hr | 4hr | Caps reset requests per IP per day |
emailLimiter | ${email} | 5 | 24hr | 4hr | Caps reset requests per email per day |
uniLimiter (union) | ${IP}_${email} | burst + slow | 1s / 30min | 30min | Blocks 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.
Link generation
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:
| Field | Value |
|---|---|
purpose | PASSWORD_RESET |
subject | PASSWORD_RESET_{visitor_id} |
visitor | The user's visitor_id |
randomHashed | SHA-256 hash of the random challenge |
jti | The 164-character JTI |
Link delivery
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!" }
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.Link verification: GET /auth/reset-password
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
| Parameter | Description |
|---|---|
token | The signed temporary JWT |
random | The raw random challenge (256 hex characters from 128 bytes) |
visitor | The user's visitor_id |
reason | Must 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.purposemust equalreason(PASSWORD_RESET)payload.subjectmust equalPASSWORD_RESET_{visitor}- The SHA-256 hash of
randommust matchpayload.randomHashedviacrypto.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:
| Limiter | Key | Default points | Default duration | Default block | Purpose |
|---|---|---|---|---|---|
ipLimit | ${JTI} | 6 | 10min | 10min | Caps attempts per verification session |
ipLimit | ${IP} | 6 | 10min | 10min | Caps attempts per IP |
uniLimiter (union) | ${IP}_${visitor_id} | burst + slow | 1s / 10min | 30min / 10min | Blocks 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.
Link purpose check
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
| Property | Value |
|---|---|
| Enumeration protection | Always returns 200 on the initiation endpoint after a 3-second minimum delay |
| OAuth accounts | Users with password_hash = 'no_password' are silently skipped |
| Link integrity | Random challenge verified with crypto.timingSafeEqual; purpose and subject claims validated |
| Link reuse | JTI blocked for 20 minutes after successful POST |
| Link expiry | Determined by the temporary JWT TTL configured in magic_links |
| GET limit | Default 5 previews per link before it is considered expired |
| Breach check | HaveIBeenPwned API called before storing the new hash |
| Session revocation | All active refresh tokens invalidated on success |
| Notification | Security email sent to confirm the change; alerts the user if unauthorized |
Configuration reference
magic_links.domain to build the full reset link sent in the email.Initiation rate limiters
${IP}_${email} composite key. Defaults: 1 point, 1s duration, 30min block. Consumed only on failed email lookups.${IP}_${email} composite key. Defaults: 4 points, 30min duration, 15min block.Verification rate limiters
These use the tempPostRoutesLimiters limiter group shared with other temporary-link POST routes:
${IP}_${visitor_id}. Defaults: 1 point, 1s duration, 30min block.${IP}_${visitor_id}. Defaults: 5 points, 10min duration, 10min block.