Part 12: Authorization: roles, ownership, and the bug class behind most API breaches
We know who the caller is. Now: what may they touch? Roles, ownership checks enforced in SQL, and why a 404 is sometimes more correct than a 403.
Series: Build a production-grade REST API in Go · Part 12 of 17 Where we are: the authenticated API from Part 11, with its ownership hole still open.
At the end of Part 11 we proved the problem: every endpoint demanded a valid token, yet Bob, logged in as himself, could read and delete Ann's order and list every user. "Has a valid token" is not "may do this". The bug looks like this:
GET /orders/7 ← order 7 belongs to Alice. Bob is logged in. What happens?
If the code just fetches order 7 and returns it, Bob reads Alice's order. If he iterates /orders/1, /orders/2, /orders/3..., he reads everyone's. This is the IDOR (Insecure Direct Object Reference) bug, also called Broken Object Level Authorization. It is consistently the #1 risk on the OWASP API Security Top 10, because it's easy to write and invisible in normal testing: every request returns 200, and each individual handler looks fine.
The defense is to make the safe thing the only thing you can write. Here's our model.
The rules
| Resource | Who can do what |
|---|---|
GET /users (everyone's emails) |
admin only |
GET/PUT/DELETE /users/{id} |
that user, or an admin |
POST /orders |
any logged-in user, creates an order for themselves |
GET/DELETE /orders/{id} |
the order's owner, or an admin |
GET /orders |
your own orders; admins see everyone's |
And roles can't be self-assigned: there's no API call that makes you an admin (Part 11).
Rule 1: the owner comes from the token, never from the body
In earlier versions, POST /orders took a user_id in the JSON. That would let any caller create orders as someone else. Now:
internal/handlers/orders.go (excerpt)
// orderInput has NO user_id. The owner is always the authenticated caller,
// taken from the token, never from the request body. Accepting an owner id from
// the client is how "create an order as someone else" bugs happen.
// (DisallowUnknownFields also rejects a client that sends one anyway.)
type orderInput struct {
Item string `json:"item"`
Quantity int `json:"quantity"`
}
func (h *OrderHandler) Create(w http.ResponseWriter, r *http.Request) {
var in orderInput
if !h.decodeJSON(w, r, &in) {
return
}
in.Item = strings.TrimSpace(in.Item)
switch {
case in.Item == "" || utf8.RuneCountInString(in.Item) > h.limits.MaxNameLen:
h.writeError(w, r, http.StatusUnprocessableEntity, fmt.Sprintf("item is required and at most %d characters", h.limits.MaxNameLen))
return
case in.Quantity < 1 || in.Quantity > maxQuantity:
h.writeError(w, r, http.StatusUnprocessableEntity, fmt.Sprintf("quantity must be between 1 and %d", maxQuantity))
return
}
p, _ := auth.FromContext(r.Context())
o, err := h.store.Create(r.Context(), p.UserID, in.Item, in.Quantity)
if errors.Is(err, storage.ErrUserNotFound) {
// The token is valid but its user was deleted since: treat as unauthenticated.
h.writeError(w, r, http.StatusUnauthorized, "account no longer exists")
return
}
if err != nil {
h.internalError(w, r, "create order", err)
return
}
h.writeJSON(w, r, http.StatusCreated, o)
}
orderInput has no user_id field, and because decoding rejects unknown fields (Part 3), a client that sends one gets a 400. The owner is p.UserID, taken from the verified token via the context.
The general principle: anything the client sends is a claim; anything the server derives from a verified credential is a fact. Identity must come from the second kind.
Rule 2: enforce ownership inside the SQL
The tempting implementation of GET /orders/{id}:
order, _ := store.Get(ctx, id) // fetch
if order.UserID != caller.UserID { // check
return forbidden
}
It works, but it's fragile: every new endpoint must remember the check, one forgetful path creates a hole, and fetch-then-compare is racy. Instead, we push the rule into the query so the unsafe version can't be written:
internal/storage/orders.go (excerpt)
// Get fetches one order. ownerID > 0 restricts the lookup to that user's
// orders, so another user's order is simply "not found". Doing the ownership
// check INSIDE the SQL (rather than fetch-then-compare in Go) is atomic and
// makes it impossible to forget the check on a code path.
func (s *OrderStore) Get(ctx context.Context, id, ownerID int) (*Order, error) {
o, err := scanOrder(s.pool.QueryRow(ctx,
"SELECT "+orderCols+" FROM orders WHERE id = $1 AND ($2::bigint = 0 OR user_id = $2)", id, ownerID))
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get order %d: %w", id, err)
}
return o, nil
}
// Delete removes an order; ownerID > 0 restricts it to that user's orders (see Get).
func (s *OrderStore) Delete(ctx context.Context, id, ownerID int) error {
tag, err := s.pool.Exec(ctx,
"DELETE FROM orders WHERE id = $1 AND ($2::bigint = 0 OR user_id = $2)", id, ownerID)
if err != nil {
return fmt.Errorf("delete order %d: %w", id, err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
SELECT ... FROM orders WHERE id = $1 AND ($2::bigint = 0 OR user_id = $2)
The second argument is the owner scope: a regular user's id, or 0 meaning "any owner" for admins. If Bob asks for Alice's order, the query simply finds no row, so the store returns ErrNotFound and the handler answers 404. There's no "fetch then forget to check" path, because there's no fetch without the scope. Where does the scope come from?
internal/handlers/orders.go (excerpt)
// scope returns the owner filter every query must apply: the caller's own id,
// or 0 ("any owner") for admins.
func scope(r *http.Request) int {
p, _ := auth.FromContext(r.Context())
if p.IsAdmin() {
return 0
}
return p.UserID
}
// List returns the caller's orders. Admins see everyone's and may filter with
// ?user_id=. Regular users cannot ask for another user's orders (403).
func (h *OrderHandler) List(w http.ResponseWriter, r *http.Request) {
userID := scope(r)
if v := r.URL.Query().Get("user_id"); v != "" {
id, err := strconv.Atoi(v)
if err != nil || id <= 0 {
h.writeError(w, r, http.StatusBadRequest, "invalid user_id")
return
}
if userID != 0 && id != userID {
h.writeError(w, r, http.StatusForbidden, "forbidden")
return
}
userID = id
}
after, limit, ok := h.pageParams(w, r)
if !ok {
return
}
orders, more, err := h.store.List(r.Context(), userID, after, limit)
if err != nil {
h.internalError(w, r, "list orders", err)
return
}
writePage(h.base, w, r, orders, more, func(o *storage.Order) int { return o.ID })
}
scope(r) reads the principal from the context: 0 for admins, the user's id otherwise. Every order handler passes it to the store. Changing Get's signature to require an ownerID argument means the compiler forces every caller to decide. That's the best kind of security: one the type system enforces.
Rule 3: the right status code when access is denied
Both 403 Forbidden and 404 Not Found are defensible; the choice leaks information:
- 404 for orders when you don't own them. A
403would confirm "that order exists, just not for you", which lets an attacker enumerate valid ids. With404, "doesn't exist" and "isn't yours" are indistinguishable. - 403 for
/users/{id}andGET /users, because user ids aren't secret and the caller is authenticated; hiding the existence of a user id protects nothing, and a clear403helps legitimate clients debug.
internal/handlers/users.go (excerpt)
// authorizeSelf allows the user themselves or an admin, otherwise answers 403.
// 403 (not 404) is fine here: user ids are not secret, and the caller is
// authenticated; they just may not act on someone else's account.
func (h *UserHandler) authorizeSelf(w http.ResponseWriter, r *http.Request, id int) bool {
p, _ := auth.FromContext(r.Context())
if p.UserID == id || p.IsAdmin() {
return true
}
h.writeError(w, r, http.StatusForbidden, "forbidden")
return false
}
// List is admin-only: it exposes every user's name and email.
func (h *UserHandler) List(w http.ResponseWriter, r *http.Request) {
if p, _ := auth.FromContext(r.Context()); !p.IsAdmin() {
h.writeError(w, r, http.StatusForbidden, "forbidden")
return
}
after, limit, ok := h.pageParams(w, r)
if !ok {
return
}
users, more, err := h.store.List(r.Context(), after, limit)
if err != nil {
h.internalError(w, r, "list users", err)
return
}
writePage(h.base, w, r, users, more, func(u *storage.User) int { return u.ID })
}
Whichever you choose, choose it deliberately and consistently, and write the reasoning down (as these comments do).
For GET /orders?user_id=X, a regular user asking for someone else's id gets 403 (an explicit request for something forbidden), while the default (no filter) quietly returns only their own.
Rule 4: separate "who can ask" from "what exists"
The User JSON includes role, but update requests don't accept it:
PUT /users/5 {"name":"x","role":"admin"} → 400 (unknown field "role")
Because userInput only has name, privilege escalation through the update endpoint is structurally impossible, not "checked and rejected". This is the same trick as Rule 1: don't validate dangerous input, don't accept it at all.
Testing authorization
Authorization bugs are invisible to happy-path tests, so test the attacks. The test below plays two users and an admin:
internal/handlers/handlers_test.go (excerpt)
func TestOrderOwnership(t *testing.T) {
e := newEnv(t)
ann, bob := e.register("ann@example.com"), e.register("bob@example.com")
admin := e.admin("root@example.com")
// Validation, and the owner can NOT be chosen by the client.
for _, bad := range []string{`{"item":"","quantity":1}`, `{"item":"b","quantity":0}`, `{"item":"b","quantity":10001}`,
`{"item":"b","quantity":1,"user_id":2}`} {
if got, _ := e.do("POST", "/orders", ann.AccessToken, bad); got != 422 && got != 400 {
t.Errorf("%s: status %d", bad, got)
}
}
status, body := e.do("POST", "/orders", ann.AccessToken, `{"item":"book","quantity":2}`)
if status != 201 {
t.Fatalf("create: %d %s", status, body)
}
var o struct {
ID int `json:"id"`
UserID int `json:"user_id"`
}
_ = json.Unmarshal([]byte(body), &o)
if o.UserID != ann.User.ID {
t.Fatalf("order owner = %d, want the caller %d", o.UserID, ann.User.ID)
}
path := fmt.Sprintf("/orders/%d", o.ID)
// IDOR checks: Bob guessing Ann's order id learns nothing, and changes nothing.
if got, _ := e.do("GET", path, bob.AccessToken, ""); got != 404 {
t.Errorf("bob get: %d, want 404", got)
}
if got, _ := e.do("DELETE", path, bob.AccessToken, ""); got != 404 {
t.Errorf("bob delete: %d, want 404", got)
}
if got, _ := e.do("GET", fmt.Sprintf("/orders?user_id=%d", ann.User.ID), bob.AccessToken, ""); got != 403 {
t.Errorf("bob listing ann's orders: %d, want 403", got)
}
_, list := e.do("GET", "/orders", bob.AccessToken, "")
if !strings.Contains(list, `"data":[]`) {
t.Errorf("bob sees orders that are not his: %s", list)
}
_, list = e.do("GET", "/orders", ann.AccessToken, "")
if !strings.Contains(list, `"item":"book"`) {
t.Errorf("ann cannot see her order: %s", list)
}
// Owner and admin can read; admin can filter by user.
if got, _ := e.do("GET", path, ann.AccessToken, ""); got != 200 {
t.Errorf("ann get: %d", got)
}
if got, _ := e.do("GET", path, admin.AccessToken, ""); got != 200 {
t.Errorf("admin get: %d", got)
}
if got, _ := e.do("GET", fmt.Sprintf("/orders?user_id=%d", ann.User.ID), admin.AccessToken, ""); got != 200 {
t.Errorf("admin filter: %d", got)
}
if got, _ := e.do("GET", "/orders?user_id=x", admin.AccessToken, ""); got != 400 {
t.Errorf("bad filter: %d", got)
}
// Deleting the account cascades to the orders.
if got, _ := e.do("DELETE", path, ann.AccessToken, ""); got != 204 {
t.Errorf("owner delete: %d", got)
}
if got, _ := e.do("DELETE", path, ann.AccessToken, ""); got != 404 {
t.Errorf("delete again: %d", got)
}
}
Notice the structure: Ann creates an order, then Bob tries every operation on it (get, delete, list with Ann's id) and each must fail. These tests are the executable form of the table at the top of this post. Another test walks every protected endpoint with no token, a garbage token and an expired token and demands 401 each time:
internal/handlers/handlers_test.go (excerpt)
func TestAuthRequired(t *testing.T) {
e := newEnv(t)
endpoints := []struct{ method, path string }{
{"GET", "/auth/me"}, {"GET", "/users"}, {"GET", "/users/1"}, {"PUT", "/users/1"}, {"DELETE", "/users/1"},
{"GET", "/orders"}, {"POST", "/orders"}, {"GET", "/orders/1"}, {"DELETE", "/orders/1"},
}
// An expired-but-correctly-signed token (same secret, negative TTL).
expiredTM, _ := auth.NewTokenManager(secret, "test", -time.Minute)
expired, _ := expiredTM.Issue(1, "admin")
for _, ep := range endpoints {
for name, tok := range map[string]string{"none": "", "garbage": "abc.def.ghi", "expired": expired} {
if got, _ := e.do(ep.method, ep.path, tok, `{}`); got != 401 {
t.Errorf("%s %s with %s token: status %d, want 401", ep.method, ep.path, name, got)
}
}
}
// Public endpoints stay public.
if got, _ := e.do("POST", "/auth/login", "", `{"email":"a@b.co","password":"xxxxxxxxxxxx"}`); got != 401 {
t.Fatalf("login should be reachable (401 for bad creds), got %d", got)
}
}
If someone adds a route and forgets protected(...), adding it to that list turns the omission into a failing test.
Complete code for this part
Everything to type, file by file. orders.go (both layers) is rewritten around "the owner comes from the token" and "scope the SQL by owner"; users.go gains the self-or-admin and admin-only rules. Then go build ./....
Change internal/handlers/orders.go
--- a/internal/handlers/orders.go
+++ b/internal/handlers/orders.go
@@ -10,6 +10,7 @@ import (
"strings"
"unicode/utf8"
+ "go-rest/internal/auth"
"go-rest/internal/storage"
)
@@ -17,9 +18,9 @@ const maxQuantity = 10_000
type OrderStore interface {
List(ctx context.Context, userID, afterID, limit int) ([]*storage.Order, bool, error) // userID 0 = all
- Get(ctx context.Context, id int) (*storage.Order, error)
+ Get(ctx context.Context, id, ownerID int) (*storage.Order, error) // ownerID 0 = any
Create(ctx context.Context, userID int, item string, quantity int) (*storage.Order, error)
- Delete(ctx context.Context, id int) error
+ Delete(ctx context.Context, id, ownerID int) error
}
type OrderHandler struct {
@@ -31,22 +32,40 @@ func NewOrderHandler(store OrderStore, log *slog.Logger, limits Limits) *OrderHa
return &OrderHandler{base: base{log: log, limits: limits}, store: store}
}
+// orderInput has NO user_id. The owner is always the authenticated caller,
+// taken from the token, never from the request body. Accepting an owner id from
+// the client is how "create an order as someone else" bugs happen.
+// (DisallowUnknownFields also rejects a client that sends one anyway.)
type orderInput struct {
- UserID int `json:"user_id"`
Item string `json:"item"`
Quantity int `json:"quantity"`
}
-// List supports an optional filter and pagination:
-// GET /orders?user_id=3&limit=20&cursor=...
+// scope returns the owner filter every query must apply: the caller's own id,
+// or 0 ("any owner") for admins.
+func scope(r *http.Request) int {
+ p, _ := auth.FromContext(r.Context())
+ if p.IsAdmin() {
+ return 0
+ }
+ return p.UserID
+}
+
+// List returns the caller's orders. Admins see everyone's and may filter with
+// ?user_id=. Regular users cannot ask for another user's orders (403).
func (h *OrderHandler) List(w http.ResponseWriter, r *http.Request) {
- userID := 0
+ userID := scope(r)
if v := r.URL.Query().Get("user_id"); v != "" {
- var err error
- if userID, err = strconv.Atoi(v); err != nil || userID <= 0 {
+ id, err := strconv.Atoi(v)
+ if err != nil || id <= 0 {
h.writeError(w, r, http.StatusBadRequest, "invalid user_id")
return
}
+ if userID != 0 && id != userID {
+ h.writeError(w, r, http.StatusForbidden, "forbidden")
+ return
+ }
+ userID = id
}
after, limit, ok := h.pageParams(w, r)
if !ok {
@@ -65,7 +84,7 @@ func (h *OrderHandler) Get(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- o, err := h.store.Get(r.Context(), id)
+ o, err := h.store.Get(r.Context(), id, scope(r))
if err != nil {
h.storeError(w, r, "get order", err)
return
@@ -80,9 +99,6 @@ func (h *OrderHandler) Create(w http.ResponseWriter, r *http.Request) {
}
in.Item = strings.TrimSpace(in.Item)
switch {
- case in.UserID <= 0:
- h.writeError(w, r, http.StatusUnprocessableEntity, "user_id is required")
- return
case in.Item == "" || utf8.RuneCountInString(in.Item) > h.limits.MaxNameLen:
h.writeError(w, r, http.StatusUnprocessableEntity, fmt.Sprintf("item is required and at most %d characters", h.limits.MaxNameLen))
return
@@ -91,11 +107,11 @@ func (h *OrderHandler) Create(w http.ResponseWriter, r *http.Request) {
return
}
- o, err := h.store.Create(r.Context(), in.UserID, in.Item, in.Quantity)
+ p, _ := auth.FromContext(r.Context())
+ o, err := h.store.Create(r.Context(), p.UserID, in.Item, in.Quantity)
if errors.Is(err, storage.ErrUserNotFound) {
- // The URL itself is valid; the *body* references something that
- // doesn't exist, so 422 is more accurate than 404.
- h.writeError(w, r, http.StatusUnprocessableEntity, "user_id does not exist")
+ // The token is valid but its user was deleted since: treat as unauthenticated.
+ h.writeError(w, r, http.StatusUnauthorized, "account no longer exists")
return
}
if err != nil {
@@ -110,7 +126,7 @@ func (h *OrderHandler) Delete(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- if err := h.store.Delete(r.Context(), id); err != nil {
+ if err := h.store.Delete(r.Context(), id, scope(r)); err != nil {
h.storeError(w, r, "delete order", err)
return
}
Change internal/handlers/users.go
--- a/internal/handlers/users.go
+++ b/internal/handlers/users.go
@@ -8,6 +8,7 @@ import (
"strings"
"unicode/utf8"
+ "go-rest/internal/auth"
"go-rest/internal/storage"
)
@@ -17,7 +18,6 @@ import (
type UserStore interface {
List(ctx context.Context, afterID, limit int) ([]*storage.User, bool, error)
Get(ctx context.Context, id int) (*storage.User, error)
- Create(ctx context.Context, name string) (*storage.User, error)
Update(ctx context.Context, id int, name string) (*storage.User, error)
Delete(ctx context.Context, id int) error
}
@@ -38,7 +38,24 @@ type userInput struct {
Name string `json:"name"`
}
+// authorizeSelf allows the user themselves or an admin, otherwise answers 403.
+// 403 (not 404) is fine here: user ids are not secret, and the caller is
+// authenticated; they just may not act on someone else's account.
+func (h *UserHandler) authorizeSelf(w http.ResponseWriter, r *http.Request, id int) bool {
+ p, _ := auth.FromContext(r.Context())
+ if p.UserID == id || p.IsAdmin() {
+ return true
+ }
+ h.writeError(w, r, http.StatusForbidden, "forbidden")
+ return false
+}
+
+// List is admin-only: it exposes every user's name and email.
func (h *UserHandler) List(w http.ResponseWriter, r *http.Request) {
+ if p, _ := auth.FromContext(r.Context()); !p.IsAdmin() {
+ h.writeError(w, r, http.StatusForbidden, "forbidden")
+ return
+ }
after, limit, ok := h.pageParams(w, r)
if !ok {
return
@@ -53,7 +70,7 @@ func (h *UserHandler) List(w http.ResponseWriter, r *http.Request) {
func (h *UserHandler) Get(w http.ResponseWriter, r *http.Request) {
id, ok := h.parseID(w, r)
- if !ok {
+ if !ok || !h.authorizeSelf(w, r, id) {
return
}
user, err := h.store.Get(r.Context(), id)
@@ -64,22 +81,9 @@ func (h *UserHandler) Get(w http.ResponseWriter, r *http.Request) {
h.writeJSON(w, r, http.StatusOK, user)
}
-func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
- in, ok := h.decodeUser(w, r)
- if !ok {
- return
- }
- created, err := h.store.Create(r.Context(), in.Name)
- if err != nil {
- h.internalError(w, r, "create user", err)
- return
- }
- h.writeJSON(w, r, http.StatusCreated, created)
-}
-
func (h *UserHandler) Update(w http.ResponseWriter, r *http.Request) {
id, ok := h.parseID(w, r)
- if !ok {
+ if !ok || !h.authorizeSelf(w, r, id) {
return
}
in, ok := h.decodeUser(w, r)
@@ -96,7 +100,7 @@ func (h *UserHandler) Update(w http.ResponseWriter, r *http.Request) {
func (h *UserHandler) Delete(w http.ResponseWriter, r *http.Request) {
id, ok := h.parseID(w, r)
- if !ok {
+ if !ok || !h.authorizeSelf(w, r, id) {
return
}
if err := h.store.Delete(r.Context(), id); err != nil {
Replace the contents of internal/routes/routes.go with
package routes
import (
"net/http"
"go-rest/internal/handlers"
)
// Deps are the handlers plus the authentication middleware to guard them.
type Deps struct {
Auth *handlers.AuthHandler
Users *handlers.UserHandler
Orders *handlers.OrderHandler
Authenticate func(http.Handler) http.Handler
}
// SetupRoutes registers every endpoint. Reading this file answers "which routes
// are public?": only those registered with `public`.
func SetupRoutes(mux *http.ServeMux, d Deps) {
public := func(pattern string, h http.HandlerFunc) { mux.Handle(pattern, h) }
protected := func(pattern string, h http.HandlerFunc) { mux.Handle(pattern, d.Authenticate(h)) }
public("POST /auth/register", d.Auth.Register)
public("POST /auth/login", d.Auth.Login)
public("POST /auth/refresh", d.Auth.Refresh)
public("POST /auth/logout", d.Auth.Logout)
protected("GET /auth/me", d.Auth.Me)
protected("GET /users", d.Users.List) // admin only (checked in the handler)
protected("GET /users/{id}", d.Users.Get)
protected("PUT /users/{id}", d.Users.Update)
protected("DELETE /users/{id}", d.Users.Delete)
protected("GET /orders", d.Orders.List)
protected("POST /orders", d.Orders.Create)
protected("GET /orders/{id}", d.Orders.Get)
protected("DELETE /orders/{id}", d.Orders.Delete)
}
Change internal/storage/orders.go
--- a/internal/storage/orders.go
+++ b/internal/storage/orders.go
@@ -83,8 +83,13 @@ func (s *OrderStore) List(ctx context.Context, userID, afterID, limit int) ([]*O
return orders, more, nil
}
-func (s *OrderStore) Get(ctx context.Context, id int) (*Order, error) {
- o, err := scanOrder(s.pool.QueryRow(ctx, "SELECT "+orderCols+" FROM orders WHERE id = $1", id))
+// Get fetches one order. ownerID > 0 restricts the lookup to that user's
+// orders, so another user's order is simply "not found". Doing the ownership
+// check INSIDE the SQL (rather than fetch-then-compare in Go) is atomic and
+// makes it impossible to forget the check on a code path.
+func (s *OrderStore) Get(ctx context.Context, id, ownerID int) (*Order, error) {
+ o, err := scanOrder(s.pool.QueryRow(ctx,
+ "SELECT "+orderCols+" FROM orders WHERE id = $1 AND ($2::bigint = 0 OR user_id = $2)", id, ownerID))
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
@@ -113,8 +118,10 @@ func (s *OrderStore) Create(ctx context.Context, userID int, item string, quanti
return o, nil
}
-func (s *OrderStore) Delete(ctx context.Context, id int) error {
- tag, err := s.pool.Exec(ctx, "DELETE FROM orders WHERE id = $1", id)
+// Delete removes an order; ownerID > 0 restricts it to that user's orders (see Get).
+func (s *OrderStore) Delete(ctx context.Context, id, ownerID int) error {
+ tag, err := s.pool.Exec(ctx,
+ "DELETE FROM orders WHERE id = $1 AND ($2::bigint = 0 OR user_id = $2)", id, ownerID)
if err != nil {
return fmt.Errorf("delete order %d: %w", id, err)
}
Change internal/storage/users.go
--- a/internal/storage/users.go
+++ b/internal/storage/users.go
@@ -110,16 +110,6 @@ func (s *UserStore) Get(ctx context.Context, id int) (*User, error) {
return u, nil
}
-// Create adds a user without credentials. Part 12 removes it: accounts are created through registration.
-func (s *UserStore) Create(ctx context.Context, name string) (*User, error) {
- u, err := scanUser(s.pool.QueryRow(ctx,
- "INSERT INTO users (name) VALUES ($1) RETURNING "+userCols, name))
- if err != nil {
- return nil, fmt.Errorf("insert user: %w", err)
- }
- return u, nil
-}
-
// CreateWithCredentials registers a normal (role=user) account. email must
// already be normalised (trimmed, lower-cased) and passwordHash already hashed.
func (s *UserStore) CreateWithCredentials(ctx context.Context, name, email, passwordHash string) (*User, error) {
Try it
Re-run the scenario that ended Part 11 (here the admin you created with make admin in Part 11 is user 1, Ann is 2, Bob is 3):
# Ann creates an order. No user_id in the body any more: the owner is whoever the token says.
$ curl -XPOST localhost:7800/orders -H "Authorization: Bearer $ANN" -d '{"item":"book","quantity":2}'
{"id":1,"user_id":2,"item":"book","quantity":2,"created_at":"2026-10-24T02:09:56.095932+05:30"}
# Trying to name an owner is rejected outright (unknown field)
$ curl -w ' [HTTP %{http_code}]\n' -XPOST localhost:7800/orders -H "Authorization: Bearer $ANN" -d '{"user_id":3,"item":"book","quantity":2}'
{"error":"invalid JSON body"} [HTTP 400]
# Bob, who could read and delete this order at the end of Part 11:
$ curl -w ' [HTTP %{http_code}]\n' localhost:7800/orders/1 -H "Authorization: Bearer $BOB"
{"error":"not found"} [HTTP 404]
$ curl -o /dev/null -w 'DELETE [HTTP %{http_code}]\n' -XDELETE localhost:7800/orders/1 -H "Authorization: Bearer $BOB"
DELETE [HTTP 404]
# ...sees none of Ann's orders, and cannot ask for them by id:
$ curl localhost:7800/orders -H "Authorization: Bearer $BOB"
{"data":[]}
$ curl -o /dev/null -w 'orders?user_id=2 [HTTP %{http_code}]\n' "localhost:7800/orders?user_id=2" -H "Authorization: Bearer $BOB"
orders?user_id=2 [HTTP 403]
# ...and the user list is for admins only:
$ curl -w ' [HTTP %{http_code}]\n' localhost:7800/users -H "Authorization: Bearer $BOB"
{"error":"forbidden"} [HTTP 403]
$ curl "localhost:7800/users?limit=2" -H "Authorization: Bearer $ADMIN"
{"data":[{"id":1,"name":"admin","email":"root@example.com","role":"admin", ...},{"id":2,"name":"Ann", ...}],"next_cursor":"Mg"}
The same requests that returned 200 and 204 at the end of Part 11 now answer 404 and 403.
One loose end: POST /users
Accounts can only come from /auth/register now (they need a password), so the old POST /users is removed: the route, the handler, and UserStore.Create. Calling it answers 405 Method Not Allowed. The "Complete code" section above deletes it from all three places.
A checklist you can reuse on any API
- Every route authenticated by default; public ones are explicit exceptions.
- For each resource, write down who may read, create, update, delete. (The table at the top.)
- Identity from the token, not the body.
- Scope queries by owner in SQL, and make the scope a required parameter.
- Don't accept fields the caller shouldn't set (role, owner, id) at all.
- Choose 403 vs 404 on purpose.
- Write tests that attack: wrong user, no token, expired token, tampered token.
- Log the user id on every request (Part 11 did) so incidents can be reconstructed.
What this model doesn't cover
- Fine-grained permissions: we have two roles. Real systems grow to permissions ("orders:refund"), teams and organizations (RBAC/ABAC). The structure (identity from the token, scope in the query) scales to those; only the policy gets richer.
- Multi-tenancy: with tenants, every table needs a tenant id in every query. PostgreSQL row-level security can enforce that in the database itself, a robust option worth studying.
- Audit trails: who changed what, and when, as durable records (not just logs).
Recap
- Authentication says who; authorization says what. Test them separately.
- IDOR: ids in URLs are guessable; every object access must check ownership.
- Take identity from the token; scope SQL by owner; make the scope a required argument.
- Don't accept privileged fields at all; structure beats validation.
- 404 vs 403 is an information-disclosure decision.
- Test the attacker's path, not just the user's.
Exercises
- Add
GET /users/{id}/orders(the orders of one user). Which scope rule applies? Write the attack test first. - A new engineer adds
PUT /orders/{id}and writesstore.Update(ctx, id, quantity)with no owner parameter. How would you restructure the code so this mistake fails to compile or fails a test? - Read about PostgreSQL row-level security (
CREATE POLICY). Sketch how it would replace the$2::bigint = 0 OR user_id = $2clause, and what it would take to setapp.current_user_idper request safely with a connection pool.
Comments
Join the discussionNo comments yet. Be the first to comment.
Build a production-grade REST API in Go
A hands-on series: start with under 200 lines of in-memory CRUD and end with Postgres, JWT auth, rate limiting, TLS and a full observability stack.