Skip to content

Password reset ​

Forgot password is verification-email's cousin: random token, expiry, one-time use, then set a new hash. Never email the old password - you don't have it.

Flow ​

  1. User submits email on /forgot-password
  2. API creates type = 'reset' token (if user exists)
  3. Email link → SPA /reset-password?token=…
  4. User enters new password → POST /api/auth/reset with token + password
  5. API updates password_hash, marks token used

Always respond the same way whether the email exists or not - enumeration is rude and useful to attackers.

Forgot endpoint ​

ts
authRouter.post('/forgot', async (req, res, next) => {
  try {
    const email = String(req.body?.email ?? '').trim().toLowerCase()
    const user = findUserByEmail(email)

    if (user) {
      // invalidate older reset tokens (optional but nice)
      db.prepare(
        `UPDATE email_tokens SET used_at = datetime('now')
         WHERE user_id = ? AND type = 'reset' AND used_at IS NULL`,
      ).run(user.id)

      const token = createEmailToken(user.id, 'reset', 1) // 1 hour
      const link = `${config.appUrl}/reset-password?token=${token}`
      await sendMail({
        to: user.email,
        subject: 'Reset your password',
        text: `Reset link (1h): ${link}`,
        html: `<p><a href="${link}">Reset your password</a> (expires in 1 hour)</p>`,
      })
    }

    res.json({ ok: true, message: 'If that email exists, we sent a reset link.' })
  } catch (err) {
    next(err)
  }
})

Reset endpoint ​

ts
authRouter.post('/reset', async (req, res, next) => {
  try {
    const token = String(req.body?.token ?? '')
    const password = String(req.body?.password ?? '')
    if (password.length < 8) throw new HttpError(400, 'password too short')

    const row = db
      .prepare(`SELECT * FROM email_tokens WHERE token = ? AND type = 'reset'`)
      .get(token) as
      | { user_id: number; expires_at: string; used_at: string | null }
      | undefined

    if (!row || row.used_at) throw new HttpError(400, 'invalid token')
    if (new Date(row.expires_at) < new Date()) throw new HttpError(400, 'token expired')

    const password_hash = await hashPassword(password)
    db.prepare(`UPDATE users SET password_hash = ? WHERE id = ?`).run(password_hash, row.user_id)
    db.prepare(`UPDATE email_tokens SET used_at = datetime('now') WHERE token = ?`).run(token)

    // optional: revoke JWTs if you had a token version / blocklist
    res.json({ ok: true })
  } catch (err) {
    next(err)
  }
})

Comments for the code above:

  • Short expiry on reset tokens (1h) beats 7-day verify links
  • Mark used immediately so replay doesn't work
  • Old JWTs still work until expiry unless you add a token_version column - worth knowing as a limitation of stateless JWT

SPA pages (sketch) ​

Forgot

ts
await fetch('/api/auth/forgot', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email }),
})
// always show success toast

Reset

ts
const token = new URLSearchParams(location.search).get('token')
await fetch('/api/auth/reset', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ token, password: newPassword }),
})
// redirect to /login

Security checklist ​

  • [ ] Same response for unknown emails on /forgot
  • [ ] Tokens random + long (crypto.randomBytes(32))
  • [ ] Expiry enforced
  • [ ] One-time use enforced
  • [ ] New password hashed with bcrypt
  • [ ] HTTPS in production (link tokens in URLs are sensitive)

Try it in the companion ​

bash
# register first, then:
curl -s -X POST http://localhost:3000/api/auth/forgot \
  -H 'Content-Type: application/json' \
  -d '{"email":"ada@example.com"}'

Watch the server console for the Ethereal preview URL, open the reset link, then POST /api/auth/reset.

As you can see, it's the same token machinery as verify - different copy, shorter TTL, updates password_hash instead of email_verified_at.

Next: hardening - validation, helmet, rate limits, JWT footguns.

Best,

-- ł.

Notes-style Express tutorial for front-end folks