The reset email contains a link, which strongly implies it can be opened later or on another device. But the verification code is stored only in the session (trackNewRequest() → $this->sessionSet('verify', …)) and checked against the session at completion (step4_completeReset() → if(empty($verify) || $verify !== $this->sessionGet('verify'))). The t token lives in the portable process_forgot_password DB row, but the code does not — so completion can only ever succeed in the exact browser session that initiated the request. The link gives a false impression of portability.
This bites hard on modern iOS Safari, which frequently evicts or re-mints the session cookie between requesting the reset and completing it — ITP, Private Browsing, the app-switch to Mail and back, and iCloud Private Relay all trigger it (the same eviction that causes the known CSRF-token-mismatch reports on iOS). When the cookie is gone on return, sessionGet('verify') is empty, execute() falls back to step 1, and the user is silently bounced to the start — no error — and can never complete the reset. In production we saw a steady trickle of users (overwhelmingly iOS Safari) who requested a reset, got the code, never completed, then repeatedly failed login with "Invalid password" (they'd typed a new password that never actually saved).
Suggested enhancement (opt-in)
Allow the verification code to be carried in a signed, single-use, expiring token (HMAC over code + user id + expiry), so completion can succeed from a fresh session. This keeps the token-as-credential model, stays single-use and time-limited, and would be opt-in so the current same-session default is preserved for anyone who wants the stricter binding. (The commercial NiftyPasswordsPlus module already implements exactly this and resolves the iOS failures — mentioned as an existence proof, not a plug.)
Repro
Request a reset in one browser session, then complete it via the emailed link in a different session (fresh private window, or after clearing the session cookie). It fails and returns to step 1. On iOS Safari this happens on its own via cookie eviction during the Mail→Safari round-trip.
Prior art
Forum: "modifying ProcessForgotPassword to ignore session?" (2013) — same underlying same-session limitation, pre-ITP.
The reset email contains a link, which strongly implies it can be opened later or on another device. But the verification code is stored only in the session (
trackNewRequest()→$this->sessionSet('verify', …)) and checked against the session at completion (step4_completeReset()→if(empty($verify) || $verify !== $this->sessionGet('verify'))). Thettoken lives in the portableprocess_forgot_passwordDB row, but the code does not — so completion can only ever succeed in the exact browser session that initiated the request. The link gives a false impression of portability.This bites hard on modern iOS Safari, which frequently evicts or re-mints the session cookie between requesting the reset and completing it — ITP, Private Browsing, the app-switch to Mail and back, and iCloud Private Relay all trigger it (the same eviction that causes the known CSRF-token-mismatch reports on iOS). When the cookie is gone on return,
sessionGet('verify')is empty,execute()falls back to step 1, and the user is silently bounced to the start — no error — and can never complete the reset. In production we saw a steady trickle of users (overwhelmingly iOS Safari) who requested a reset, got the code, never completed, then repeatedly failed login with "Invalid password" (they'd typed a new password that never actually saved).Suggested enhancement (opt-in)
Allow the verification code to be carried in a signed, single-use, expiring token (HMAC over code + user id + expiry), so completion can succeed from a fresh session. This keeps the token-as-credential model, stays single-use and time-limited, and would be opt-in so the current same-session default is preserved for anyone who wants the stricter binding. (The commercial NiftyPasswordsPlus module already implements exactly this and resolves the iOS failures — mentioned as an existence proof, not a plug.)
Repro
Request a reset in one browser session, then complete it via the emailed link in a different session (fresh private window, or after clearing the session cookie). It fails and returns to step 1. On iOS Safari this happens on its own via cookie eviction during the Mail→Safari round-trip.
Prior art
Forum: "modifying ProcessForgotPassword to ignore session?" (2013) — same underlying same-session limitation, pre-ITP.