BOLA and IDOR Bugs in Go API Handlers and How to Stop Them
Reproduce a BOLA and IDOR bug in a Go handler, see why random ids only hide it, and fix it by making ownership part of every lookup, with tests.

Broken object level authorisation, abbreviated BOLA and older known as IDOR (insecure direct object reference), has been first on the OWASP API Security list since the list was written. It does not look like a vulnerability in code review. It looks like an ordinary handler that fetches an object and returns it. The vulnerability is the question the handler did not ask: whose object is this?
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article reproduces the bug in Go, shows why the two popular half-measures do not fix it, and lays out the structure that makes it hard to write. It is closely related to mass assignment, which is the same failure for fields instead of objects, and it is the bug at the centre of multi-tenant leaks. It ran on Go 1.25.6.
The bug
A user is logged in. The API has GET /orders/{id} and DELETE /orders/{id}. Authentication middleware verifies the token and identifies the caller. The handler then loads the order by id and returns it.
What it never did is compare the order's owner with the caller. Authentication answers "who are you?" Authorisation answers "may you do this to that?" Having the first does not give you the second, and the id in the URL is attacker-controlled input like any other.
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
)
type Order struct {
ID string `json:"id"`
Owner string `json:"-"`
Item string `json:"item"`
Total int `json:"total_cents"`
}
type Store struct {
mu sync.Mutex
orders map[string]*Order
random bool
seq int
}
func (s *Store) add(owner, item string, total int) string {
s.mu.Lock()
defer s.mu.Unlock()
s.seq++
id := strconv.Itoa(s.seq)
if s.random {
b := make([]byte, 12)
rand.Read(b)
id = "ord_" + hex.EncodeToString(b)
}
s.orders[id] = &Order{ID: id, Owner: owner, Item: item, Total: total}
return id
}
// get is the safe lookup: the owner is part of the query, so a foreign id is indistinguishable from a missing one.
func (s *Store) get(owner, id string) (*Order, bool) {
s.mu.Lock()
defer s.mu.Unlock()
o, ok := s.orders[id]
if !ok || o.Owner != owner {
return nil, false
}
return o, true
}
func (s *Store) getAny(id string) (*Order, bool) {
s.mu.Lock()
defer s.mu.Unlock()
o, ok := s.orders[id]
return o, ok
}
// user stands in for authentication middleware: a verified token would set this.
func user(r *http.Request) string { return r.Header.Get("X-Test-User") }
func (s *Store) handler(safe bool) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
var o *Order
var ok bool
if safe {
o, ok = s.get(user(r), r.PathValue("id"))
} else {
o, ok = s.getAny(r.PathValue("id")) // authenticated, but never asked whose order this is
}
if !ok {
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(o)
})
mux.HandleFunc("DELETE /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
var o *Order
var ok bool
if safe {
o, ok = s.get(user(r), r.PathValue("id"))
} else {
o, ok = s.getAny(r.PathValue("id"))
}
if !ok {
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
return
}
s.mu.Lock()
delete(s.orders, o.ID)
s.mu.Unlock()
w.WriteHeader(http.StatusNoContent)
})
return mux
}
func newStore(random bool) (*Store, map[string][]string) {
s := &Store{orders: map[string]*Order{}, random: random}
ids := map[string][]string{}
for _, o := range []struct {
owner, item string
total int
}{{"alice", "keyboard", 7900}, {"bob", "monitor", 21900}, {"bob", "desk", 34900}, {"carol", "chair", 15900}, {"alice", "mouse", 2900}} {
ids[o.owner] = append(ids[o.owner], s.add(o.owner, o.item, o.total))
}
return s, ids
}
func call(h http.Handler, method, path, as string) (int, string) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(method, path, nil)
req.Header.Set("X-Test-User", as)
h.ServeHTTP(rec, req)
return rec.Code, rec.Body.String()
}
func main() {
// 1. The bug, with sequential ids: mallory is a legitimate user with no orders.
s, ids := newStore(false)
h := s.handler(false)
fmt.Println("vulnerable handler, sequential ids:")
stolen := 0
for i := 1; i <= 8; i++ {
if code, _ := call(h, "GET", "/orders/"+strconv.Itoa(i), "mallory"); code == 200 {
stolen++
}
}
fmt.Println(" mallory enumerated ids 1..8 and read", stolen, "orders that are not hers")
code, _ := call(h, "DELETE", "/orders/"+ids["bob"][0], "mallory")
fmt.Println(" mallory deleted bob's order:", code)
// 2. Random ids do not fix the bug, they only make guessing harder.
s, ids = newStore(true)
h = s.handler(false)
found := 0
for i := 1; i <= 8; i++ {
if code, _ := call(h, "GET", "/orders/"+strconv.Itoa(i), "mallory"); code == 200 {
found++
}
}
code, _ = call(h, "GET", "/orders/"+ids["bob"][0], "mallory")
fmt.Printf("vulnerable handler, random ids: enumeration found %d, but a leaked id still works: %d\n", found, code)
// 3. The fix: ownership is part of the lookup.
s, ids = newStore(false)
h = s.handler(true)
fmt.Println("fixed handler:")
for _, tc := range []struct{ label, method, path, as string }{
{"alice reads her order", "GET", "/orders/" + ids["alice"][0], "alice"},
{"mallory reads alice's order", "GET", "/orders/" + ids["alice"][0], "mallory"},
{"mallory deletes bob's order", "DELETE", "/orders/" + ids["bob"][0], "mallory"},
{"mallory asks for an id that is missing", "GET", "/orders/9999", "mallory"},
} {
code, body := call(h, tc.method, tc.path, tc.as)
fmt.Printf(" %-40s %d %s\n", tc.label, code, strings.TrimSpace(body))
}
}vulnerable handler, sequential ids:
mallory enumerated ids 1..8 and read 5 orders that are not hers
mallory deleted bob's order: 204
vulnerable handler, random ids: enumeration found 0, but a leaked id still works: 200
fixed handler:
alice reads her order 200 {"id":"1","item":"keyboard","total_cents":7900}
mallory reads alice's order 404 {"error":"not found"}
mallory deletes bob's order 404 {"error":"not found"}
mallory asks for an id that is missing 404 {"error":"not found"}The first section of the output is the vulnerable handler with sequential ids. Mallory is a real, logged-in user with no orders. She requests ids 1 through 8 and reads five orders that belong to other people. Then she deletes Bob's order and gets a 204. Nothing about her requests looks unusual to a rate limiter or a web application firewall. They are well-formed, authenticated and polite.
Why random ids are not the fix
The usual first response is to switch to UUIDs or random strings. The second section of the output shows what that buys. Enumeration of small numbers finds nothing, which is a real improvement: an attacker cannot walk the id space.
But the same handler still returns 200 when Mallory presents Bob's order id. And ids leak constantly: in emails, in shared links, in logs, in referrer headers, in a support ticket screenshot, in another API's response, in a browser history synced to a shared computer. A system whose security depends on nobody ever learning an id is one leak away from the first scenario.
Use random ids anyway. They reduce enumeration, avoid revealing how many orders you have, and make scraping harder. Treat them as defence in depth. The control is the ownership check.
Why hiding the button is not the fix
A front end that only shows a user their own orders is also not a control. The API is the boundary. Attackers do not use your front end; they use curl and a list of ids. Every authorisation decision must be made on the server, on every request.
The fix: ownership is part of the lookup
The third section shows the fixed handler. The store method takes the owner along with the id, and treats an order that exists but is owned by someone else exactly like an order that does not exist:
func (s *Store) get(owner, id string) (*Order, bool) {
o, ok := s.orders[id]
if !ok || o.Owner != owner {
return nil, false
}
return o, true
}Alice reads her order and gets 200. Mallory asks for Alice's order and gets 404. She deletes Bob's order and gets 404. She asks for an id that does not exist and gets the same 404. The three failure cases are indistinguishable, which is the point: a 403 would confirm that the order exists, and that is information she should not have. If you must distinguish, keep it to cases where the caller already knows the resource exists, such as one they can see but may not change.
In SQL, the same idea is a WHERE clause: SELECT ... FROM orders WHERE id = $1 AND user_id = $2. If no row comes back, return 404. Do not load first and compare afterwards in handler code, because the next developer will write a new handler and copy the load.
Where to put the check
The structural question is where authorisation happens, and the answer that scales is: as low as possible, in the data access layer, with the caller's identity as a required argument. If the only way to read an order is store.OrderFor(ctx, id), which reads the user from the context, then a handler cannot forget it, because there is nothing else to call. The multi-tenant article makes the same argument for tenant ids.
For rules that go beyond ownership (a manager may read their team's orders, support staff may read any order but not delete), write a small policy function that takes the actor, the action and the object, and call it from the service layer. Keep it in one package where it can be read and tested as a table. Scattering if user.Role == "admin" through handlers is how a rule ends up applied in nine routes and missed in the tenth.
The routes people forget
IDOR is rarely in the endpoint you were thinking about. Check all of these:
- Every verb on the resource. The
GETis guarded and thePUT,PATCHorDELETEis not. The lab's delete handler shows it. - Nested resources.
GET /users/{userID}/orders/{orderID}: the path says whose orders, but does the code verify the caller is that user, and that the order belongs to that user? Both checks are needed. - List and search endpoints. A filter by
?user_id=that trusts the parameter. Derive the filter from the token, not the query. - Bulk and batch endpoints. One request carrying fifty ids has fifty chances to skip the check. See bulk endpoints.
- Indirect references.
POST /invoiceswith{"order_id": "..."}. The caller can reference an order they do not own and have its details copied into their invoice. - File downloads.
/files/{id}served from storage. Presigned URLs are capabilities; make sure only the right user can obtain one. - Export, report and webhook configuration endpoints, which often have their own, less careful data access code.
- GraphQL. One query can touch many objects, each needing its own check (GraphQL in Go).
- Admin endpoints reached with normal tokens because a role check was left out.
Testing for it
IDOR tests are cheap and strangely rare. For each resource, create objects for two users, then act as the second user against the first user's ids, on every route that takes an id:
for _, route := range routes { // GET, PATCH, DELETE, and nested variants
rec := call(h, route.Method, route.Path(aliceOrderID), "mallory")
if rec.Code != 404 {
t.Errorf("%s %s as another user = %d, want 404", route.Method, route.Path, rec.Code)
}
}Generate that list from your router where you can (walk the registered patterns), so a new route cannot escape the test by being forgotten. Run the same check for tenants. Also test that a legitimate user still succeeds, or an over-eager fix that returns 404 for everything will pass.
Detecting attempts
A pattern of 404s on object ids from one account is a signal. Count not found responses per authenticated user and alert on bursts, as enumeration looks like a hundred 404s in a minute. Log the caller and the requested id (the id is not a secret; the token is). The point of returning 404 for foreign objects is that it hides existence from the caller, but your own logs should keep the distinction: record whether the object was missing or owned by someone else. That log line is how you find out an attack happened.
What to keep from this
Treat every id from the client as untrusted. Put the caller in the query. Return 404 for objects that are not theirs. Check every verb, every nested path and every list. Test as the other user. The OWASP API1 entry lists further examples, and the Go standard library's net/http routing documentation explains the PathValue mechanism the lab uses to read ids.


