Email & Password Setup
This builds on Users & Sessions. You’ll add email and password fields to the user table and hash passwords before storing them.
Before you start
Section titled “Before you start”- Go 1.22+
- Existing
users-sessionsauth code in place - A database connection via
database/sql - Go’s argon2 package:
golang.org/x/crypto/argon2 - nanoid package:
github.com/matoous/go-nanoid/v2
File layout
Section titled “File layout”Build on the file structure from Users & Sessions with these files:
/auth /passwords.go // HashPassword/VerifyPassword/CheckPasswordBreach /users.go // User model + CreateUser/GetUserByEmail + validationUpdated user table
Section titled “Updated user table”Add email, email_verified and password_hash columns:
CREATE TABLE users ( id TEXT NOT NULL PRIMARY KEY, email TEXT NOT NULL UNIQUE, email_verified INTEGER NOT NULL DEFAULT 0, password_hash TEXT NOT NULL, created_at INTEGER NOT NULL);The email needs a unique constraint so you can look up users by email and prevent duplicates. The email_verified flag tracks whether the user has confirmed their address.
Password hashing
Section titled “Password hashing”Never store passwords in plain text. Use a slow hashing algorithm designed for passwords.
Here we choose Argon2id. It won the Password Hashing Competition and resists both GPU and side-channel attacks.
Put this in auth/passwords.go:
package auth
import ( "crypto/rand" "crypto/subtle" "encoding/base64" "fmt" "strings"
"golang.org/x/crypto/argon2")
// Argon2id parameters (OWASP minimum configuration)const ( argonTime = 2 argonMemory = 19 * 1024 // 19 MB argonThreads = 1 argonKeyLen = 32 saltLen = 16)
func HashPassword(password string) (string, error) { salt := make([]byte, saltLen) if _, err := rand.Read(salt); err != nil { return "", err }
hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
// Encode as: $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash> return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, argonMemory, argonTime, argonThreads, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(hash), ), nil}
func VerifyPassword(password, encoded string) bool { parts := strings.Split(encoded, "$") if len(parts) != 6 || parts[1] != "argon2id" { return false }
var version int var memory, time uint32 var threads uint8
n, _ := fmt.Sscanf(parts[2], "v=%d", &version) if n != 1 || version != argon2.Version { return false }
n, _ = fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads) if n != 3 || memory == 0 || time == 0 || threads == 0 { return false }
salt, err := base64.RawStdEncoding.DecodeString(parts[4]) if err != nil { return false }
storedHash, err := base64.RawStdEncoding.DecodeString(parts[5]) if err != nil { return false }
hash := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(storedHash)))
return subtle.ConstantTimeCompare(hash, storedHash) == 1}The encoded format stores all the parameters with the hash. This lets you verify old hashes even if you change parameters later.
The parameters above follow OWASP recommendations: 19 MB memory, 2 iterations, 1 thread.
Validating input
Section titled “Validating input”Check email format and password length before doing anything else. Keep validation simple. You’re not trying to catch every invalid email, just obvious mistakes.
Put this in auth/users.go:
package auth
import ( "errors" "strings" "unicode/utf8")
var ( ErrInvalidEmail = errors.New("invalid email") ErrPasswordTooShort = errors.New("password must be at least 12 characters") ErrPasswordTooLong = errors.New("password too long"))
func NormalizeEmail(email string) string { email = strings.TrimSpace(email) email = strings.ToLower(email) return email}
func ValidateEmail(email string) error { // Basic check: contains @ with something on both sides if !strings.Contains(email, "@") || strings.HasPrefix(email, "@") || strings.HasSuffix(email, "@") { return ErrInvalidEmail } return nil}
func ValidatePassword(password string) error { // Minimum: count characters (runes), not bytes if utf8.RuneCountInString(password) < 12 { return ErrPasswordTooShort } // Maximum: cap bytes to prevent DoS on the hash function if len(password) > 1024 { return ErrPasswordTooLong } return nil}Normalize emails before validation and storage. Trim whitespace and lowercase. Otherwise "Joe@Example.com " and "joe@example.com" become different users. Full RFC 5321 email validation is complex. This pragmatic normalization handles the common cases.
For passwords, the minimum counts characters (runes) so users with emoji or non-ASCII input get expected behavior. The maximum caps bytes to prevent someone from submitting a massive “password” and making your server churn through Argon2.
Note: When MFA is enabled, OWASP considers passwords shorter than 8 characters weak. When MFA is not enabled, OWASP considers passwords shorter than 15 characters weak. More info here.
Checking for breached passwords
Section titled “Checking for breached passwords”Block passwords that appear in known data breaches. The Pwned Passwords API lets you check without sending the actual password. You hash it with SHA-1, send the first 5 characters, and get back all known hashes with that prefix. Then check locally if yours is in the list.
Put this in auth/passwords.go:
package auth
import ( "bufio" "errors" "crypto/sha1" "encoding/hex" "net/http" "strings" "time")
var ErrPasswordBreached = errors.New("password found in data breach")
func CheckPasswordBreach(password string) error { hash := sha1.Sum([]byte(password)) hashHex := strings.ToUpper(hex.EncodeToString(hash[:])) prefix := hashHex[:5] suffix := hashHex[5:]
client := &http.Client{Timeout: 3 * time.Second} resp, err := client.Get("https://api.pwnedpasswords.com/range/" + prefix) if err != nil { // API unavailable - fail open to avoid blocking signups return nil } defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { return nil }
scanner := bufio.NewScanner(resp.Body) for scanner.Scan() { line := scanner.Text() parts := strings.Split(line, ":") if len(parts) >= 1 && parts[0] == suffix { return ErrPasswordBreached } } if err := scanner.Err(); err != nil { return nil }
return nil}Run this check during signup and password changes.
Creating a user
Section titled “Creating a user”Put this in auth/users.go:
package auth
import ( "context" "database/sql" "errors" "strings" "time"
gonanoid "github.com/matoous/go-nanoid/v2")
var ErrEmailTaken = errors.New("email already registered")
type User struct { ID string Email string EmailVerified bool PasswordHash string CreatedAt time.Time}
func CreateUser(ctx context.Context, db *sql.DB, email, password string) (*User, error) { email = NormalizeEmail(email)
if err := ValidateEmail(email); err != nil { return nil, err } if err := ValidatePassword(password); err != nil { return nil, err } if err := CheckPasswordBreach(password); err != nil { return nil, err }
passwordHash, err := HashPassword(password) if err != nil { return nil, err }
id, err := gonanoid.New(12) if err != nil { return nil, err }
now := time.Now().UTC()
_, err = db.ExecContext(ctx, "INSERT INTO users (id, email, email_verified, password_hash, created_at) VALUES (?, ?, ?, ?, ?)", id, email, 0, passwordHash, now.Unix(), ) if err != nil { // Check for unique constraint violation if strings.Contains(err.Error(), "UNIQUE constraint failed") { return nil, ErrEmailTaken } return nil, err }
return &User{ ID: id, Email: email, EmailVerified: false, PasswordHash: passwordHash, CreatedAt: now, }, nil}The unique constraint check is SQLite-specific. Other databases return different error messages. Adjust accordingly.
The SQL placeholders in this doc use ?. This works for SQLite and MySQL but not for all SQL. PostgreSQL-style drivers use $1, $2, etc.
Finding a user by email
Section titled “Finding a user by email”Put this in auth/users.go:
func GetUserByEmail(ctx context.Context, db *sql.DB, email string) (*User, error) { email = NormalizeEmail(email)
row := db.QueryRowContext(ctx, "SELECT id, email, email_verified, password_hash, created_at FROM users WHERE email = ?", email, )
var user User var createdAt int64 var emailVerified int err := row.Scan(&user.ID, &user.Email, &emailVerified, &user.PasswordHash, &createdAt) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, err }
user.EmailVerified = emailVerified == 1 user.CreatedAt = time.Unix(createdAt, 0) return &user, nil}Next up
Section titled “Next up”Head to Password Signup & Login to wire these into HTTP handlers.