Code

The reset-token store

Five files as tabs, in dependency order: 00003_password_reset_tokens.sqlpassword_resets.sqlusers.sqlsessions.sqltoken.go (the table, its queries, and the two existing query files that grow). Then sqlc generate.

1. db/migrations/00003_password_reset_tokens.sql — the token store, mirroring sessions:

token_hash TEXT NOT NULL UNIQUE,        -- the hash, never the token
user_id    UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL,
used_at    TIMESTAMPTZ                  -- NULL until spent → single-use

used_at is the single-use flag: NULL means fresh, a timestamp means burnt.

2. internal/db/queries/password_resets.sql — four queries: CreatePasswordReset, GetValidPasswordReset (the one that enforces the rules — used_at IS NULL AND expires_at > now()), MarkPasswordResetUsed, and InvalidateUserPasswordResets (burn all outstanding for a user).

3. internal/db/queries/users.sql (modify) — one new query, UpdateUserPassword. A pure addition.

4. internal/db/queries/sessions.sql (modify) — one new query, DeleteSessionsForUser — the "log out every device" query a completed reset will call. Also a pure addition.

5. internal/auth/token.go (new) — the same generator sessions use, now exported for reuse:

func GenerateToken() (raw, hash string, err error) {  // raw → the link; hash → the DB
	b := make([]byte, 32); rand.Read(b)
	raw = base64.RawURLEncoding.EncodeToString(b)
	return raw, HashToken(raw), nil
}
func HashToken(raw string) string { /* SHA-256, hex */ }

GenerateToken returns two things: the raw token (goes in the emailed link) and its hash (goes in the database). The server never stores the raw token — so, exactly like sessions and passwords, a leaked backup exposes no usable reset links.