Skip to content

Email & Password Setup

View as Markdown

This builds on Users & Sessions. You’ll add email and password fields to the user table and hash passwords before storing them.

  • Go 1.22+
  • Existing users-sessions auth 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

Build on the file structure from Users & Sessions with these files:

/auth
/passwords.go // HashPassword/VerifyPassword/CheckPasswordBreach
/users.go // User model + CreateUser/GetUserByEmail + validation

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.

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.

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.

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.

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.

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
}

Head to Password Signup & Login to wire these into HTTP handlers.