Skip to content

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.

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 token
routes/web.go
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:

app/middlewares/rate-limit.go
PasswordResetRateLimit = NewRateLimiter(RateLimiterConfig{
MaxRequests: 3,
Window: time.Hour,
Message: "Too many password reset requests, please try again in an hour",
})

Password reset requires SMTP configuration. Without valid SMTP credentials, emails will fail silently (the handler returns the same success message regardless):

.env
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-smtp-user
SMTP_PASS=your-smtp-password
FROM_EMAIL=noreply@example.com
FROM_NAME=Laju

The 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
)

Tokens are generated using crypto/rand — 32 random bytes hex-encoded into a 64-character string:

app/services/mailer.go
func generateResetToken() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}

The MailerService.SendPasswordResetEmail method handles the full sequence: generate token → store in DB → send email.

app/services/mailer.go
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 :exec
INSERT INTO password_resets (token, user_id, email, expires_at, created_at)
VALUES (?, ?, ?, ?, ?);

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:

app/handlers/password-reset.go
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.

When the user clicks the reset link, the handler validates the token against the database:

app/handlers/password-reset.go
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 :one
SELECT * FROM password_resets WHERE token = ? AND used = 0 AND expires_at > ?;
app/services/mailer.go
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
}

The reset handler validates the token again, hashes the new password with Argon2id, updates the user, and invalidates the token:

app/handlers/password-reset.go
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.",
})
}

After a successful reset, the token is marked as used so it cannot be reused:

app/services/mailer.go
func (m *MailerService) InvalidateResetToken(ctx context.Context, token string) {
m.querier.MarkPasswordResetUsed(ctx, token)
}
-- queries/password_reset.sql
-- name: MarkPasswordResetUsed :exec
UPDATE password_resets SET used = 1 WHERE token = ?;
-- migrations/0003_create_password_resets_table.sql
CREATE 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);

Expired tokens are cleaned up by the background goroutine that runs every hour (same one that cleans expired sessions):

// cmd/laju-go/main.go — startBackgroundCleanup
querier.DeleteExpiredPasswordResets(context.Background())
-- queries/password_reset.sql
-- name: DeleteExpiredPasswordResets :exec
DELETE FROM password_resets WHERE expires_at < CURRENT_TIMESTAMP;
  • No user enumeration — the forgot-password endpoint returns the same message regardless of whether the email exists.
  • Single-use tokens — tokens are marked used = 1 after 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.