# Password reset tokens are time-limited and single-use

- **Pattern:** `ab-002227` (`saas-authentication.password-credential.password-reset-time-limited`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-002227
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-002227)

## Why it matters

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.

## Severity rationale

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.

## Remediation

Store a hash of the token, set a 1-hour expiry, and mark it used atomically on consumption — all three conditions must hold:

```ts
// 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.

## Detection

- **ID:** `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:

  ```ts
  // 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() } })
  ```

## External references

- cwe CWE-640
- cwe CWE-262
- owasp A07

Taxons: cryptography-and-secrets, access-control

HTML version: https://auditbuffet.com/patterns/ab-002227
