The reset-token store
Five files as tabs, in dependency order: 00003_password_reset_tokens.sql → password_resets.sql → users.sql → sessions.sql → token.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.