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:

  1. 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).

  2. 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

DecisionRationale
30-minute TTLShort enough to limit exposure, long enough for the user to check email
SHA256 hashed storageRaw token never stored in DB — database compromise doesn't leak usable tokens
Always returns 200POST /forgot-password returns success even for unknown emails — prevents email enumeration
Invalidate old tokensNew request invalidates all existing unused tokens for the same email
Revoke all sessionsAfter password change, all refresh tokens are deleted — forces re-login on all devices
32 bytes of randomness64 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 users to admins, and register under /api/admin/auth.
Previous
Input validation