Keep secrets out of code, sensitive fields out of responses and logs, and every credential as weak as possible.
10a
Read config once at startup, validate it, and crash loudly if anything is missing.
type Config struct { Addr string DBURL string JWTSecret []byte } func Load() (Config, error) { c := Config{ Addr: getenv("ADDR", ":8080"), DBURL: os.Getenv("DATABASE_URL"), JWTSecret: []byte(os.Getenv("JWT_SECRET")), } var errs []error if c.DBURL == "" { errs = append(errs, errors.New("DATABASE_URL is required")) } if len(c.JWTSecret) < 32 { errs = append(errs, errors.New("JWT_SECRET must be ≥ 32 bytes")) } return c, errors.Join(errs...) }
refusing to start:
DATABASE_URL is required
JWT_SECRET must be ≥ 32 bytes
ok: true addr: :8080 secret bytes: 32
10b
The same user struct, serialised and logged — before and after. All real output.
type User struct { ID int `json:"id"` Email string `json:"email"` PasswordHash string `json:"-"` // never serialised Role string `json:"role"` ResetToken string `json:"-"` CreatedAt time.Time `json:"created_at"` } // LogValue: what slog prints when a User is logged. func (u User) LogValue() slog.Value { return slog.GroupValue( slog.Int("id", u.ID), slog.String("role", u.Role), ) } type Secret string func (Secret) LogValue() slog.Value { return slog.StringValue("[REDACTED]") }
json:"-" is the safety net. Better still: a separate UserResponse struct that only has public fields.slog.LogValuer makes the type redact itself — nobody has to remember at every log call./login, /register or /auth/refresh.10c
Three database roles, tested against a real PostgreSQL 16. If the API is ever compromised, it still can't drop a table.
-- migrator owns the schema; the API only touches rows GRANT SELECT, INSERT, UPDATE ON users TO api_app; GRANT INSERT ON audit_log TO api_app; GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO api_app; -- reporting reads users, but never the hash GRANT SELECT (id, email, role) ON users TO reporting;
api_app=> INSERT INTO users(email,password_hash) VALUES (…); INSERT 0 1
api_app=> DELETE FROM users WHERE id = 1;
ERROR: permission denied for table users
api_app=> DROP TABLE users;
ERROR: must be owner of table users
api_app=> DELETE FROM audit_log;
ERROR: permission denied for table audit_log
reporting=> SELECT id, email, role FROM users; (1 row)
reporting=> SELECT password_hash FROM users;
ERROR: permission denied for table users
migrator in CI; the running API connects as api_app.10d
The 2023 list. The four in the brief are at the top.
password_hash; body sets rolejson:"-", unknown fields rejected · 10, 08/v1 or debug routesLoad() refuse to start without them.json:"password — there should be no matches.User with slog and confirm the hash never appears.Recap
json:"-" or a response struct for output; LogValuer for logs.