A non-expiring or reusable password reset token is a persistent account takeover vector: intercept it once (from a forwarded email, a shared device, or a log entry), and you can reset the password at any time in the future. CWE-640 (Weak Password Recovery Mechanism for Forgotten Password) and CWE-262 (Not Using Password Aging) both apply. OWASP ASVS V2.5 requires that recovery tokens be short-lived, high-entropy, and single-use; OWASP A07 (Identification and Authentication Failures) lists broken account recovery as a named failure mode.
High because a non-expiring or reusable reset token provides account takeover capability to anyone who ever possessed the reset link, even if that access was accidental or historical.
Store a hash of the token, set a 1-hour expiry, and mark it used atomically on consumption — all three conditions must hold:
// Generation — store hash, send raw token in the email link
const rawToken = crypto.randomBytes(32).toString('hex')
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex')
await db.passwordReset.create({
data: { tokenHash, userId, expiresAt: new Date(Date.now() + 3600_000), usedAt: null }
})
// Consumption — validate, check expiry, mark used in one query
const record = await db.passwordReset.findFirst({
where: { tokenHash, usedAt: null, expiresAt: { gt: new Date() } }
})
if (!record) return error('Invalid or expired token')
await db.passwordReset.update({ where: { id: record.id }, data: { usedAt: new Date() } })
The raw token goes in the email link; the hash goes in the database. A leaked database row does not compromise the reset link.
ID: saas-authentication.password-credential.password-reset-time-limited
Severity: high
What to look for: Find the password reset token generation and consumption code. Check: (1) the token expiry — how long is the reset link valid? (2) whether the token is invalidated after use — is there a usedAt timestamp or is the token deleted after consumption? (3) whether the token is stored as a hash in the database, not plaintext (a leaked token from the DB should not allow a reset). Count all instances found and enumerate each.
Pass criteria: Reset tokens expire within a reasonable window (1-24 hours). Tokens are invalidated immediately after use. Tokens are stored as hashes, not plaintext. At least 1 implementation must be confirmed.
Fail criteria: Reset tokens do not expire, or expire after an unreasonably long period (> 24 hours for a non-sensitive application). Tokens are not invalidated after use, allowing the same reset link to be used multiple times. Tokens are stored in plaintext.
Skip (N/A) when: No password reset flow. Signal: OAuth-only auth, no "forgot password" endpoint.
Detail on fail: "Password reset tokens at /api/auth/reset-password have no expiry field — tokens are valid forever" or "Reset token is not deleted or marked used after password is changed — same link can be reused".
Remediation: A non-expiring, reusable reset token is almost as dangerous as a session credential: it provides indefinite access to account recovery:
// Generation: store hash, record expiry
const rawToken = crypto.randomBytes(32).toString('hex')
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex')
await db.passwordReset.create({
data: {
tokenHash,
userId,
expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour
usedAt: null
}
})
// Email rawToken in the link (never the hash)
// Consumption: validate, check expiry, mark used atomically
const record = await db.passwordReset.findFirst({
where: { tokenHash, usedAt: null, expiresAt: { gt: new Date() } }
})
if (!record) return error('Invalid or expired token')
await db.passwordReset.update({ where: { id: record.id }, data: { usedAt: new Date() } })