Password Reset
Laju Go implements a self-service password reset flow using DB-backed tokens. Tokens are 32 random bytes hex-encoded, stored in the password_resets table with a 1-hour expiry, and sent to the user via SMTP email.
Flow Overview
Section titled “Flow Overview”User → POST /forgot-password (email) ↓ MailerService generates token, stores in DB, sends email ↓User clicks email link → GET /reset-password/<token> ↓ Handler validates token (not used, not expired) ↓User submits new password → POST /reset-password/<token> ↓ Handler validates token, hashes password, updates DB, invalidates tokenRoutes
Section titled “Routes”app.Get("/forgot-password", passwordResetHandler.ShowForgotPasswordForm)app.Post("/forgot-password", passwordResetHandler.SendResetLink, middlewares.PasswordResetRateLimit.Limit())app.Get("/reset-password/:token", passwordResetHandler.ShowResetPasswordForm)app.Post("/reset-password/:token", passwordResetHandler.ResetPassword)The forgot-password endpoint is rate-limited to 3 requests per hour to prevent email bombing:
PasswordResetRateLimit = NewRateLimiter(RateLimiterConfig{ MaxRequests: 3, Window: time.Hour, Message: "Too many password reset requests, please try again in an hour",})Configuration
Section titled “Configuration”Password reset requires SMTP configuration. Without valid SMTP credentials, emails will fail silently (the handler returns the same success message regardless):
SMTP_HOST=smtp.gmail.comSMTP_PORT=587SMTP_USER=your-smtp-userSMTP_PASS=your-smtp-passwordFROM_EMAIL=noreply@example.comFROM_NAME=LajuThe mailer service is initialized in cmd/laju-go/main.go:
mailerService := routes.SetupMailerService( querier, cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.FromEmail, cfg.FromName, appURL, // used to build the reset link)Token Generation
Section titled “Token Generation”Tokens are generated using crypto/rand — 32 random bytes hex-encoded into a 64-character string:
func generateResetToken() (string, error) { bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { return "", err } return hex.EncodeToString(bytes), nil}Sending the Reset Email
Section titled “Sending the Reset Email”The MailerService.SendPasswordResetEmail method handles the full sequence: generate token → store in DB → send email.
func (m *MailerService) SendPasswordResetEmail(ctx context.Context, email string, userID int64) error { token, err := generateResetToken() if err != nil { return err }
// Store token in database with 1-hour expiry if err := m.querier.CreatePasswordReset(ctx, token, userID, email, time.Now().Add(1*time.Hour)); err != nil { return fmt.Errorf("failed to store reset token: %w", err) }
// Build reset URL resetURL := fmt.Sprintf("%s/reset-password/%s", m.appURL, token)
// Send HTML email subject := "Reset Your Password" body := fmt.Sprintf(`...HTML email with reset link...`, resetURL, resetURL) return m.SendEmail(email, subject, body)}The corresponding SQL query:
-- queries/password_reset.sql-- name: CreatePasswordReset :execINSERT INTO password_resets (token, user_id, email, expires_at, created_at)VALUES (?, ?, ?, ?, ?);The Handler
Section titled “The Handler”The forgot-password handler deliberately does not reveal whether an email exists in the system. It returns the same success message whether the email was found or not:
func (h *PasswordResetHandler) SendResetLink(c *fiber.Ctx) error { var req struct { Email string `json:"email"` } if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) }
if req.Email == "" || !strings.Contains(req.Email, "@") { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Please provide a valid email address"}) }
successMsg := "If an account exists with that email, we've sent a password reset link."
// Don't reveal whether email exists (security best practice) user, err := h.userService.GetProfileByEmail(req.Email) if err != nil { return h.inertiaService.Render(c, "auth/ForgotPassword", fiber.Map{"success": successMsg}) }
if err := h.mailerService.SendPasswordResetEmail(c.Context(), user.Email, user.ID); err != nil { return h.inertiaService.Render(c, "auth/ForgotPassword", fiber.Map{"success": successMsg}) }
return h.inertiaService.Render(c, "auth/ForgotPassword", fiber.Map{"success": successMsg})}This prevents user enumeration — an attacker cannot determine valid email addresses by observing different responses.
Token Validation
Section titled “Token Validation”When the user clicks the reset link, the handler validates the token against the database:
func (h *PasswordResetHandler) ShowResetPasswordForm(c *fiber.Ctx) error { token := c.Params("token") if token == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid reset link"}) }
_, err := h.mailerService.ValidateResetToken(c.Context(), token) if err != nil { return h.inertiaService.Render(c, "auth/ResetPassword", fiber.Map{ "error": "Invalid or expired reset link", }) }
return h.inertiaService.Render(c, "auth/ResetPassword", fiber.Map{ "Title": "Reset Password", "token": token, })}The validation query checks three conditions — token must exist, not be used, and not be expired:
-- queries/password_reset.sql-- name: GetPasswordReset :oneSELECT * FROM password_resets WHERE token = ? AND used = 0 AND expires_at > ?;func (m *MailerService) ValidateResetToken(ctx context.Context, token string) (*ResetTokenEntry, error) { pr, err := m.querier.GetPasswordReset(ctx, token) if err != nil { return nil, fmt.Errorf("invalid or expired token") } return &ResetTokenEntry{ UserID: pr.UserID, Email: pr.Email, Token: pr.Token, ExpiresAt: pr.ExpiresAt, }, nil}Resetting the Password
Section titled “Resetting the Password”The reset handler validates the token again, hashes the new password with Argon2id, updates the user, and invalidates the token:
func (h *PasswordResetHandler) ResetPassword(c *fiber.Ctx) error { token := c.Params("token")
var req struct { Password string `json:"password"` PasswordConfirmed string `json:"password_confirmation"` } if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) }
// Validate password length if req.Password == "" || len(req.Password) < 8 { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must be at least 8 characters"}) }
// Validate password confirmation if req.Password != req.PasswordConfirmed { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Passwords do not match"}) }
// Validate token tokenEntry, err := h.mailerService.ValidateResetToken(c.Context(), token) if err != nil { return h.inertiaService.Render(c, "auth/ResetPassword", fiber.Map{ "token": token, "error": "Invalid or expired reset link", }) }
// Get user and hash new password user, _ := h.userService.GetProfile(tokenEntry.UserID) hashedPassword, _ := services.HashPassword(req.Password) h.userService.UpdatePassword(user.ID, hashedPassword)
// Invalidate token so it can't be reused h.mailerService.InvalidateResetToken(c.Context(), token)
return h.inertiaService.Render(c, "auth/ResetPassword", fiber.Map{ "token": token, "success": "Password reset successfully. You can now login with your new password.", })}Token Invalidation
Section titled “Token Invalidation”After a successful reset, the token is marked as used so it cannot be reused:
func (m *MailerService) InvalidateResetToken(ctx context.Context, token string) { m.querier.MarkPasswordResetUsed(ctx, token)}-- queries/password_reset.sql-- name: MarkPasswordResetUsed :execUPDATE password_resets SET used = 1 WHERE token = ?;Database Schema
Section titled “Database Schema”-- migrations/0003_create_password_resets_table.sqlCREATE TABLE IF NOT EXISTS password_resets ( token TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, email TEXT NOT NULL, expires_at DATETIME NOT NULL, used INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP);
CREATE INDEX IF NOT EXISTS idx_password_resets_user_id ON password_resets(user_id);CREATE INDEX IF NOT EXISTS idx_password_resets_expires_at ON password_resets(expires_at);Token Cleanup
Section titled “Token Cleanup”Expired tokens are cleaned up by the background goroutine that runs every hour (same one that cleans expired sessions):
// cmd/laju-go/main.go — startBackgroundCleanupquerier.DeleteExpiredPasswordResets(context.Background())-- queries/password_reset.sql-- name: DeleteExpiredPasswordResets :execDELETE FROM password_resets WHERE expires_at < CURRENT_TIMESTAMP;Security Notes
Section titled “Security Notes”- No user enumeration — the forgot-password endpoint returns the same message regardless of whether the email exists.
- Single-use tokens — tokens are marked
used = 1after a successful reset and cannot be reused. - 1-hour expiry — tokens expire after 1 hour; expired tokens are rejected by the validation query.
- Argon2id hashing — new passwords are hashed with the same Argon2id algorithm used for registration.
- Rate limiting — 3 reset requests per hour per IP prevents email bombing.
- Token in URL — the token is passed as a URL path parameter (
/reset-password/<token>), not a query string, so it doesn’t appear in server access logs that strip query strings.