Appearance
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
- User submits email on
/forgot-password - API creates
type = 'reset'token (if user exists) - Email link → SPA
/reset-password?token=… - User enters new password →
POST /api/auth/resetwith token + password - 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_versioncolumn - 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 toastReset
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 /loginSecurity 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,
-- ł.