Independent security review of #889 confirmed a pre-existing codebase-wide pattern (not a regression): approval/revocation tokens are stored PLAINTEXT in approval_token (internal/purchase/approvals.go:315; internal/config/store_postgres.go:944; also internal/email/notifications.go:121, internal/api/handler_plans.go:393). The SHA-256-before-ConstantTimeCompare on validate is only length-normalization, not hashed-at-rest. Harden: store sha256(token), compare hashes, migrate/expire existing rows. Impact: many token paths; effort ~M. Out of scope for #889. Related #1356.
Independent security review of #889 confirmed a pre-existing codebase-wide pattern (not a regression): approval/revocation tokens are stored PLAINTEXT in approval_token (internal/purchase/approvals.go:315; internal/config/store_postgres.go:944; also internal/email/notifications.go:121, internal/api/handler_plans.go:393). The SHA-256-before-ConstantTimeCompare on validate is only length-normalization, not hashed-at-rest. Harden: store sha256(token), compare hashes, migrate/expire existing rows. Impact: many token paths; effort ~M. Out of scope for #889. Related #1356.