Step 5 — Expenses, locked to their owner
CRUD again, but now every query has to know whose data it is.
- Write repository methods where every query is filtered by user ID.
- Explain why another user's record should return 404, not 403.
- Build partial updates using pointer fields for optional input.
Every command for this step
touch internal/expense/expense.go internal/expense/repository.gotouch internal/expense/service.go internal/expense/handler.gogo build ./... && go run ./cmd/api# create onecurl -s -X POST localhost:8080/v1/expenses -H "Authorization: Bearer $TOKEN" \ -d '{"amount_cents":1250,"category":"food","note":"lunch","spent_on":"2026-09-08"}' | jq# the cross-user check — register a second user firstB=$(curl -s -X POST localhost:8080/v1/auth/register \ -d '{"name":"B","email":"b@x.c","password":"password123"}' >/dev/null; \ curl -s -X POST localhost:8080/v1/auth/login \ -d '{"email":"b@x.c","password":"password123"}' | jq -r .access_token)# all three MUST be 404curl -si -H "Authorization: Bearer $B" localhost:8080/v1/expenses/1 | head -1curl -si -X PATCH -H "Authorization: Bearer $B" localhost:8080/v1/expenses/1 -d '{"note":"x"}' | head -1curl -si -X DELETE -H "Authorization: Bearer $B" localhost:8080/v1/expenses/1 | head -1The rule this whole project exists for
Requests now carry a user id. Every query must use it. There are two places you could do that check, and only one of them holds up.
// WRONG — two statements, and a check somebody will forget to writee, err := repo.GetByID(ctx, expenseID)if e.UserID != userID { return ErrForbidden}// RIGHT — one query. Not yours and not there give the same answer.err = db.QueryRowContext(ctx, `SELECT id, amount_cents, category FROM expenses WHERE id = $1 AND user_id = $2`, expenseID, userID).Scan(&e.ID, &e.AmountCents, &e.Category)/expenses/41 to /expenses/42 and reads a stranger’s spending. Under the name Broken Object Level Authorization it is first on the OWASP API Security Top 10. Putting AND user_id = $2 in the SQL means you cannot forget it.Why 404 and not 403
When somebody asks for an expense that is not theirs, return 404 Not Found, not 403 Forbidden. A 403 confirms the row exists, so an attacker can map out which ids are real without reading any of them. Not yours and not there should be indistinguishable.
File 1 — the type
package expensetype Expense struct { ID int64 `json:"id"` UserID int64 `json:"-"` // internal; the client knows who it is AmountCents int64 `json:"amount_cents"` Category string `json:"category"` Note string `json:"note"` SpentOn time.Time `json:"spent_on"` CreatedAt time.Time `json:"created_at"`}type CreateRequest struct { AmountCents int64 `json:"amount_cents"` Category string `json:"category"` Note string `json:"note"` SpentOn string `json:"spent_on"` // "2026-09-08"}// UpdateRequest uses pointers so "not sent" and "sent as empty" are different.type UpdateRequest struct { AmountCents *int64 `json:"amount_cents"` Category *string `json:"category"` Note *string `json:"note"`}File 2 — the repository, every method taking a userID
// Notice: EVERY method takes userID. There is no way to call one without it.func (r *Repository) GetByID(ctx context.Context, id, userID int64) (*Expense, error) { var e Expense err := r.db.QueryRowContext(ctx, `SELECT id, user_id, amount_cents, category, note, spent_on, created_at FROM expenses WHERE id = $1 AND user_id = $2`, id, userID, ).Scan(&e.ID, &e.UserID, &e.AmountCents, &e.Category, &e.Note, &e.SpentOn, &e.CreatedAt) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound // covers both "not there" and "not yours" } if err != nil { return nil, err } return &e, nil}func (r *Repository) Delete(ctx context.Context, id, userID int64) error { res, err := r.db.ExecContext(ctx, `DELETE FROM expenses WHERE id = $1 AND user_id = $2`, id, userID) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound } return nil}File 3 — the service, with the rules
func (s *Service) Create(ctx context.Context, userID int64, req CreateRequest) (*Expense, error) { if req.AmountCents <= 0 { return nil, fmt.Errorf("%w: amount must be more than zero", ErrValidation) } if strings.TrimSpace(req.Category) == "" { return nil, fmt.Errorf("%w: category is required", ErrValidation) } spentOn, err := time.Parse("2006-01-02", req.SpentOn) if err != nil { return nil, fmt.Errorf("%w: spent_on must look like 2026-09-08", ErrValidation) } if spentOn.After(time.Now()) { return nil, fmt.Errorf("%w: cannot record a future expense", ErrValidation) } e := &Expense{ UserID: userID, // from the token, never from the body AmountCents: req.AmountCents, Category: strings.ToLower(strings.TrimSpace(req.Category)), Note: req.Note, SpentOn: spentOn, } return e, s.repo.Create(ctx, e)}File 4 — the handler
func (h *Handler) get(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserID(r.Context()) if !ok { httpx.Error(w, 401, "unauthorized") return } id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { httpx.Error(w, 400, "invalid id") return } e, err := h.svc.GetByID(r.Context(), id, userID) // both ids, always if err != nil { httpx.FromError(w, err) // ErrNotFound becomes 404 return } httpx.JSON(w, 200, e)}Partial updates and why they need pointers
PUT replaces a whole resource. PATCH changes some of it. The problem in Go is telling "the client set the note to empty" apart from "the client did not mention the note". Both arrive as "".
A pointer answers it. nil means the field was absent from the JSON; a pointer to an empty string means they sent it and meant it.
func (s *Service) Update(ctx context.Context, id, userID int64, req UpdateRequest) (*Expense, error) { e, err := s.repo.GetByID(ctx, id, userID) // scoped read first if err != nil { return nil, err } if req.AmountCents != nil { if *req.AmountCents <= 0 { return nil, fmt.Errorf("%w: amount must be more than zero", ErrValidation) } e.AmountCents = *req.AmountCents } if req.Category != nil { e.Category = strings.ToLower(*req.Category) } if req.Note != nil { e.Note = *req.Note // "" is a real value here } return e, s.repo.Update(ctx, e, userID)}The test you must run by hand
# as user AA=$(login a@x.com); ID=$(create_expense $A)# as user B — all three must be 404curl -si -H "Authorization: Bearer $B" localhost:8080/v1/expenses/$ID | head -1curl -si -X PATCH -H "Authorization: Bearer $B" localhost:8080/v1/expenses/$ID | head -1curl -si -X DELETE -H "Authorization: Bearer $B" localhost:8080/v1/expenses/$ID | head -1- Put ownership in the SQL:
WHERE id = $1 AND user_id = $2. Never a separate check in Go. - Return 404 for someone else’s row. A 403 confirms it exists.
- Make
userIDa required parameter of every repository method so it cannot be skipped. - Pointer fields in a PATCH request separate "not sent" from "sent as empty".
- 1Remove
AND user_id = $2from one query and re-run that test to watch it fail. - 2Try to create an expense with someone else’s
user_idin the JSON body and confirm it is ignored. - 3Send a future date and confirm it is rejected.
- 4PATCH with
{}, then{"note":""}, then{"note":"x"}— three different outcomes from one pointer field.