---
title: Verification & Recovery
description: Email verification codes, password reset tokens, and password changes.
---
import LLMExport from '../../components/LLMExport.astro';
" + html.EscapeString(message) + "
")) } func HandleSendVerificationCode(db *sql.DB, mailer Mailer) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { session := auth.GetSession(r.Context()) if session == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } user, err := auth.GetUserByID(r.Context(), db, session.UserID) if err != nil || user == nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } if user.EmailVerified { http.Error(w, "Email already verified", http.StatusBadRequest) return } code, err := auth.CreateEmailVerificationCode(r.Context(), db, user.ID, user.Email) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } err = mailer.SendVerificationEmail(user.Email, code) if err != nil { http.Error(w, "Failed to send email", http.StatusInternalServerError) return } http.Redirect(w, r, "/verify-email", http.StatusSeeOther) } } func HandleVerifyEmail(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { session := auth.GetSession(r.Context()) if session == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } code := r.FormValue("code") if code == "" { renderFormError(w, http.StatusBadRequest, "Verification failed", "Code required", "/verify-email") return } err := auth.VerifyEmailCode(r.Context(), db, session.UserID, code) if errors.Is(err, auth.ErrInvalidCode) { renderFormError(w, http.StatusBadRequest, "Verification failed", "Invalid or expired code", "/verify-email") return } if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } http.Redirect(w, r, "/login?verified=1", http.StatusSeeOther) } } ``` ### Rate limit verification requests Don't let users spam the verification endpoint. Limit to about 10 attempts per hour per user. See [Rate Limiting](/rate-limiting) for implementation details. ## Password reset When users forget their password, send them a reset link with a secure token. ### Reset token table ```sql CREATE TABLE password_reset_tokens ( id TEXT NOT NULL PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), token_hash BLOB NOT NULL, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL ); CREATE INDEX password_reset_tokens_user_id_idx ON password_reset_tokens(user_id); ``` ### Creating a reset token Put this in `auth/recovery.go`: ```go const PasswordResetExpiry = 1 * time.Hour type PasswordResetToken struct { ID string UserID string TokenHash []byte ExpiresAt time.Time CreatedAt time.Time } func CreatePasswordResetToken(ctx context.Context, db *sql.DB, userID string) (string, error) { // Delete any existing tokens for this user _, err := db.ExecContext(ctx, "DELETE FROM password_reset_tokens WHERE user_id = ?", userID) if err != nil { return "", err } token, err := gonanoid.New(32) if err != nil { return "", err } id, err := gonanoid.New() if err != nil { return "", err } tokenHash := sha256.Sum256([]byte(token)) now := time.Now().UTC() expiresAt := now.Add(PasswordResetExpiry) _, err = db.ExecContext(ctx, "INSERT INTO password_reset_tokens (id, user_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?)", id, userID, tokenHash[:], expiresAt.Unix(), now.Unix(), ) if err != nil { return "", err } return token, nil } ``` Reset tokens get 32 characters (about 190 bits of entropy). Hash them with SHA-256 before storage. If someone gets your database, they can't use the tokens. Set expiration to one hour. That's long enough for legitimate users but limits the window for attackers. ### Using the token Put this in `auth/recovery.go`: ```go var ErrInvalidToken = errors.New("invalid or expired token") var ErrPasswordsDoNotMatch = errors.New("passwords do not match") func ResetPassword(ctx context.Context, db *sql.DB, token, newPassword, confirmPassword string) error { if newPassword != confirmPassword { return ErrPasswordsDoNotMatch } if err := ValidatePassword(newPassword); err != nil { return err } if err := CheckPasswordBreach(newPassword); err != nil { return err } newHash, err := HashPassword(newPassword) if err != nil { return err } tx, err := db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() // Atomically consume the token - only one caller can delete it tokenHash := sha256.Sum256([]byte(token)) row := tx.QueryRowContext(ctx, `DELETE FROM password_reset_tokens WHERE token_hash = ? AND expires_at > ? RETURNING user_id`, tokenHash[:], time.Now().UTC().Unix(), ) var userID string err = row.Scan(&userID) if errors.Is(err, sql.ErrNoRows) { return ErrInvalidToken } if err != nil { return err } // Update password _, err = tx.ExecContext(ctx, "UPDATE users SET password_hash = ? WHERE id = ?", newHash, userID, ) if err != nil { return err } // Invalidate all sessions _, err = tx.ExecContext(ctx, "DELETE FROM sessions WHERE user_id = ?", userID) if err != nil { return err } return tx.Commit() } ``` This also uses `DELETE ... RETURNING`. If your database does not support it, use a transaction that selects the token row first, checks expiry, then deletes and updates. After a password reset, invalidate all existing sessions. If someone stole the account and the real owner resets the password, the attacker gets logged out. ### Reset handlers Put this in `handlers/recovery.go`: ```go package handlers import ( "database/sql" "errors" "fmt" "net/http" auth "github.com/.../auth" ) // Reuse renderFormError from handlers/verification.go. func HandleForgotPassword(db *sql.DB, mailer Mailer) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { email := r.FormValue("email") if email == "" { renderFormError(w, http.StatusBadRequest, "Forgot password", "Email required", "/forgot-password") return } // Always return the same response to prevent email enumeration user, err := auth.GetUserByEmail(r.Context(), db, email) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } if user != nil { token, err := auth.CreatePasswordResetToken(r.Context(), db, user.ID) if err == nil { resetURL := fmt.Sprintf("https://example.com/reset-password?token=%s", token) _ = mailer.SendPasswordResetEmail(user.Email, resetURL) } } // Same message whether user exists or not http.Redirect(w, r, "/forgot-password?sent=1", http.StatusSeeOther) } } func HandleResetPassword(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { token := r.FormValue("token") newPassword := r.FormValue("password") confirmPassword := r.FormValue("password_confirm") if token == "" || newPassword == "" { renderFormError(w, http.StatusBadRequest, "Reset password", "Token and password required", "/reset-password?token="+token) return } err := auth.ResetPassword(r.Context(), db, token, newPassword, confirmPassword) if errors.Is(err, auth.ErrInvalidToken) { renderFormError(w, http.StatusBadRequest, "Reset password", "Invalid or expired reset link", "/forgot-password") return } if errors.Is(err, auth.ErrPasswordsDoNotMatch) || errors.Is(err, auth.ErrPasswordTooShort) || errors.Is(err, auth.ErrPasswordTooLong) || errors.Is(err, auth.ErrPasswordBreached) { renderFormError(w, http.StatusBadRequest, "Reset password", err.Error(), "/reset-password?token="+token) return } if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } http.Redirect(w, r, "/login?reset=1", http.StatusSeeOther) } } ``` The forgot password handler returns the same response whether the email exists or not. This prevents attackers from using it to discover which emails are registered. ### Referrer policy Set a strict Referrer-Policy header on your reset password page. Otherwise the token might leak in the Referer header if users click external links. Set this header in your reset-password page handler (example shown in the rendering section below). ## Route wiring Put this in `main.go`: ```go allowedOrigin := "https://example.com" requireSameOrigin := func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !auth.VerifyRequestOrigin(r, allowedOrigin) { http.Error(w, "Forbidden", http.StatusForbidden) return } next.ServeHTTP(w, r) }) } mux.Handle("POST /verify/send", requireSameOrigin(auth.RequireSession(handlers.HandleSendVerificationCode(db, mailer)))) mux.Handle("POST /verify/confirm", requireSameOrigin(auth.RequireSession(handlers.HandleVerifyEmail(db)))) mux.Handle("POST /password/forgot", requireSameOrigin(handlers.HandleForgotPassword(db, mailer))) mux.Handle("POST /password/reset", requireSameOrigin(handlers.HandleResetPassword(db))) // Optional page rendering routes mux.Handle("GET /verify-email", auth.RequireSession(http.HandlerFunc(handlers.HandleVerifyEmailPage))) mux.HandleFunc("GET /forgot-password", handlers.HandleForgotPasswordPage) mux.HandleFunc("GET /reset-password", handlers.HandleResetPasswordPage) ``` This applies origin checks to every state-changing verification and recovery route. ## Rendering pages Put this in `handlers/pages.go`: ```go package handlers import ( "html/template" "net/http" ) func HandleVerifyEmailPage(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`