Recipes
Password reset
The standalone app includes a complete password reset flow — request a reset email, validate the token, and update the password. This recipe explains the pattern so you can adapt it or add similar token-based flows.
How it works
The flow is two API calls:
POST /api/auth/forgot-password— User submits their email. The server generates a random token, stores its hash, and sends the token via email. Always returns 200 (prevents email enumeration).POST /api/auth/reset-password— User submits the token and a new password. The server validates the token, updates the password, and revokes all existing sessions.
Migration
The password_reset_tokens table stores hashed tokens:
func (m *CreatePasswordResetTokens) Up(db *sqlite.DB) error {
_, err := db.Exec(`CREATE TABLE password_reset_tokens (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
token_hash TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`)
return err
}
Requesting a reset
// POST /api/auth/forgot-password
// Body: {"email": "user@example.com"}
// 1. Validate email format
v := validate.Fields(
validate.Required("email", req.Email),
validate.Email("email", req.Email),
)
// 2. Look up user — if not found, return 200 anyway
row := db.QueryRow("SELECT id FROM users WHERE email = ? AND deleted_at IS NULL AND is_active = 1", email)
if err := row.Scan(&userID); err != nil {
// User not found — return success to prevent enumeration
http.WriteJSON(w, http.StatusOK, successResponse)
return
}
// 3. Invalidate existing unused tokens for this email
db.Exec("UPDATE password_reset_tokens SET used_at = ? WHERE email = ? AND used_at IS NULL", now, email)
// 4. Generate token (32 bytes = 64 hex chars), store SHA256 hash
token := generateToken() // crypto/rand
tokenHash := auth.HashToken(token)
db.Exec("INSERT INTO password_reset_tokens ...")
// 5. Send email with the raw token
client.Send(ctx, email.Message{
To: []string{userEmail},
Subject: "Password Reset",
HTML: htmlTemplate,
})
Confirming the reset
// POST /api/auth/reset-password
// Body: {"token": "abc123...", "password": "new-password"}
// 1. Hash the submitted token and look it up
tokenHash := auth.HashToken(req.Token)
row := db.QueryRow("SELECT id, email, expires_at FROM password_reset_tokens WHERE token_hash = ? AND used_at IS NULL", tokenHash)
// 2. Check expiration (30 minute TTL)
if time.Now().After(expiresAt) {
http.WriteError(w, http.StatusBadRequest, "reset token has expired")
return
}
// 3. Update password
passwordHash, _ := auth.HashPassword(req.Password)
db.Exec("UPDATE users SET password = ? WHERE email = ?", passwordHash, tokenEmail)
// 4. Mark token as used
db.Exec("UPDATE password_reset_tokens SET used_at = ? WHERE id = ?", now, tokenID)
// 5. Revoke all refresh tokens — forces re-login
db.Exec("DELETE FROM refresh_tokens WHERE entity_type = 'user' AND entity_id = ?", userID)
Security design
| Decision | Rationale |
|---|---|
| 30-minute TTL | Short enough to limit exposure, long enough for the user to check email |
| SHA256 hashed storage | Raw token never stored in DB — database compromise doesn't leak usable tokens |
| Always returns 200 | POST /forgot-password returns success even for unknown emails — prevents email enumeration |
| Invalidate old tokens | New request invalidates all existing unused tokens for the same email |
| Revoke all sessions | After password change, all refresh tokens are deleted — forces re-login on all devices |
| 32 bytes of randomness | 64 hex characters from crypto/rand — brute force is infeasible |
Automatic cleanup
The built-in purge-old-reset-tokens cron job runs daily at 4:30 AM and deletes expired or used tokens older than 7 days.
Testing
# Request a reset
curl -s -X POST http://localhost:23710/api/auth/forgot-password \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com"}'
# Confirm the reset (use the token from the email or server logs)
curl -s -X POST http://localhost:23710/api/auth/reset-password \
-H "Content-Type: application/json" \
-d '{"token": "abc123...", "password": "new-secure-password"}'
In local development without Resend configured, the token is logged at WARN level so you can copy it from the console.
Tips
- Email is best-effort. If Resend is down, the token is still stored. The user can request another reset.
- No rate limiting on forgot-password. The auth route group already has rate limiting (20 req/min per IP). No additional rate limiting needed.
- Adapt for admin password reset. Fork the module, change the table from
userstoadmins, and register under/api/admin/auth.