Session 10 · 🗝️ Hygiene

Secrets & OWASP API Top 10

Keep secrets out of code, sensitive fields out of responses and logs, and every credential as weak as possible.

✓
10aconfig & secrets
✓
10bdata exposure
✓
10cleast privilege
✓
10dOWASP API Top 10

10a

Secrets come from the environment — or the app won't start

Read config once at startup, validate it, and crash loudly if anything is missing.

✗ NEVER
  • const jwtSecret = "changeme"
  • .env committed to git
  • secrets in Dockerfile / README
  • keys pasted into Slack
✓ INSTEAD
  • os.Getenv at startup
  • .env in .gitignore, .env.example committed
  • secret manager / platform env vars
  • gitleaks in CI
internal/config/config.go
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...)
}
go run ./demos/10 · JWT_SECRET=changeme, no DATABASE_URL — then fixed
refusing to start:
DATABASE_URL is required
JWT_SECRET must be ≥ 32 bytes
ok: true addr: :8080 secret bytes: 32
→Fail at boot, not at the first request. A missing secret should never become a running server.
→A secret that ever reached git is leaked — deleting the file keeps it in history. Rotate it.

10b

What leaves the server: responses and logs

The same user struct, serialised and logged — before and after. All real output.

internal/user/user.go
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.
→Never log request bodies of /login, /register or /auth/refresh.

10c

Least privilege: every credential does one job

Three database roles, tested against a real PostgreSQL 16. If the API is ever compromised, it still can't drop a table.

CREATE / DROP
SELECT users
INSERT / UPDATE
DELETE
password_hash
migrator
✓
✓
✓
✓
✓
api_app
✗
✓
✓
✗
✓
reporting
✗
3 cols
✗
✗
✗
migrations/0002_roles.sql
-- 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;
psql · each statement with its real result (condensed) · as api_app, then 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
→Migrations run as migrator in CI; the running API connects as api_app.
→The audit log is append-only for the API — an attacker can't erase their tracks.
→Same rule for API keys: scoped (read-only, one service), short-lived, rotatable.

10d

OWASP API Security Top 10 — and where this track fixes each

The 2023 list. The four in the brief are at the top.

Risk
What goes wrong
Fix · session
API1 · BOLA
user 7 reads /users/8
OwnerOrAdmin · 06
API2 · Broken Authentication
weak hashes, forged or eternal tokens, unlimited guessing
bcrypt, JWT checks, rotation, rate limit · 01–04, 08
API3 · Object Property Authz
response leaks password_hash; body sets role
json:"-", unknown fields rejected · 10, 08
API4 · Resource Consumption
huge bodies, request floods
MaxBytesReader, limiter, timeouts · 08–09
API5 · BFLA
a user calls an admin function
RequirePerm · 05
API6 · Sensitive Business Flows
bots abuse sign-up or checkout
per-account limits · 08
API7 · SSRF
server fetches a URL the user supplied
allowlist hosts before fetching
API8 · Security Misconfiguration
open CORS, missing headers, no TLS
CORS, headers, TLS · 09
API9 · Inventory Management
forgotten /v1 or debug routes
list every route; remove old ones
API10 · Unsafe API Consumption
trusting a third-party API's data
validate it like user input · 08
→BOLA vs BFLA: BOLA = the wrong object (someone else's row). BFLA = the wrong function (an admin endpoint).
→Session 11 wires every fix above into one server.
TASK

Audit your own API against the list

  • Move every secret to env vars; make Load() refuse to start without them.
  • Grep for json:"password — there should be no matches.
  • Log a User with slog and confirm the hash never appears.
  • For each of API1–API10, write one line: "fixed by … / not applicable because …".

Recap

Session 10 in four lines

1Secrets from env, validated at boot; anything that touched git gets rotated.
2json:"-" or a response struct for output; LogValuer for logs.
3Separate DB roles: migrator owns, API reads/writes rows, reporting reads some columns.
4BOLA, broken auth, property exposure and BFLA are the ones that bite first.