Authentication has become a solved-enough problem that most teams barely think about it: OIDC, SSO, a middleware that verifies the token and stamps a user onto the request context. Authorization is the opposite. It is a decision that has to be made per endpoint, per HTTP method, and per object — and unlike authentication, there is no single choke point where forgetting it is impossible. Miss one handler, and any authenticated user can read, modify, or delete resources that belong to someone else.
The scale is not anecdotal. In the OWASP Top 10:2025, Broken Access Control holds the #1 position, and the contributed data set found some form of it in 100% of the applications tested. The category spans 40 CWEs, over 1.8 million occurrences, and roughly 32,000 related CVEs. This post walks through why access control fails so reliably, the classic object-level flaw known as IDOR, a concrete vulnerable handler and its fix in Go, and a testing strategy that actually catches the bug before an attacker does.
Why Authorization Breaks While Authentication Doesn’t
The structural reason is the default. If you fail to verify a session token, the middleware returns 401 and the request goes nowhere — the failure mode of missing authentication is “denied”. If you forget an authorization check, the request succeeds — the failure mode of missing authorization is “allowed”. A codebase accumulates endpoints the way a city accumulates doors, and every one of them defaults to open.
There is a second reason: authentication is generic while authorization is domain-specific. Auth middleware is written once and reviewed often. “Can this user see this invoice?” is written forty times, inline, in forty handlers, and each instance is a fresh chance to get it wrong — or to skip it entirely under deadline pressure, with a mental note to circle back that quietly becomes permanent.
The Anatomy of an IDOR
The most common species of broken access control is the insecure direct object reference: the API exposes a resource by its identifier and never verifies that the authenticated actor owns that resource. Here is a handler that looks completely professional and is completely broken:
package api
import (
"database/sql"
"encoding/json"
"log"
"net/http"
)
type ctxKey int
const userIDKey ctxKey = 0
type Invoice struct {
ID int
UserID int
Amount int
Status string
}
// GET /api/invoices/{id}
func (s *Server) getInvoice(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value(userIDKey).(int)
_ = userID // ownership check was planned but never implemented
invoiceID := r.PathValue("id")
var inv Invoice
err := s.db.QueryRowContext(r.Context(),
"SELECT id, user_id, amount, status FROM invoices WHERE id = $1",
invoiceID,
).Scan(&inv.ID, &inv.UserID, &inv.Amount, &inv.Status)
if err == sql.ErrNoRows {
http.NotFound(w, r)
return
}
if err != nil {
log.Printf("load invoice %s: %v", invoiceID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(inv); err != nil {
log.Printf("encode invoice: %v", err)
}
}
Walk through what this code does and does not do. It authenticates — the context middleware guarantees a valid user, whose ID is read at the top. It validates that the invoice exists. It even passes the user ID into userID, presumably for use elsewhere. What it never does is ask the only question that matters: does this invoice belong to this user? The query filters by ID alone. Any logged-in customer can enumerate /api/invoices/1, /api/invoices/2, and so on and read every other customer’s billing data. The userID variable, fetched and never used in the query, is a monument to the check that was meant to be written.
Note the subtlety that makes this bug survive code review: nothing about it looks wrong. There is no SQL injection, no missing nil check, no error swallowed. The authorization decision is not a line you can point to being buggy — it is a line that isn’t there at all. Tests pass because the tests exercise the documented flow: a user requests their own invoice, and it comes back correctly.
The Fix: Scope the Query to the Owner
The strongest fix moves the ownership check into the query itself, so the database — not a branch of if-statements — is the enforcement point:
err := s.db.QueryRowContext(r.Context(),
"SELECT id, user_id, amount, status FROM invoices WHERE id = $1 AND user_id = $2",
invoiceID, userID,
).Scan(&inv.ID, &inv.UserID, &inv.Amount, &inv.Status)
With the owner in the WHERE clause, an invoice belonging to someone else is indistinguishable from an invoice that does not exist, and the existing sql.ErrNoRows branch handles it. Respond with 404 rather than 403 in these paths: a 404 does not confirm to an attacker that the object exists, whereas a 403 turns the endpoint into an oracle for enumerating valid IDs.
This “scoped query” pattern scales better than a check after loading the row, because the query simply cannot return a row the caller does not own. If a handler genuinely needs an unscoped query — admin tooling, background jobs — make that visible and deliberate: a code comment explaining why, or a separately named repository method that reviewers learn to treat as sensitive.
It Is Bigger Than Object Reads
IDOR on GET endpoints is the headline case, but the 2025 category is wide. The canonical example on the OWASP page is an API with missing access controls on POST, PUT, and DELETE — function-level access control, where the read path is protected but the destructive write paths are not. Related members of the same category include:
- Mass assignment: binding a request body directly into a model and persisting it lets callers set fields they should never touch —
role,is_admin,tenant_id. Use explicit allow-listed DTOs per endpoint. - Server-Side Request Forgery: CWE-918 is mapped into A01 in the 2025 edition. A “fetch this URL” feature that follows internal addresses lets an attacker pivot into your private network.
- Cross-Site Request Forgery: CWE-352 sits in the same category — state-changing endpoints that rely on ambient cookie auth without CSRF tokens or SameSite enforcement.
The practical reading is that access control is not one vulnerability you fix once; it is a property of every state-changing and data-reading path in the system.
Testing Authorization Like an Attacker
Tests fail to catch IDOR because they model the honest client. The fix is to write every authorization-sensitive endpoint a test that acts as a second, unprivileged user and asserts the request fails. For the handler above:
package api
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
// The context plumbing is repeated here so this test compiles on its own;
// keep a single copy in your actual codebase.
type ctxKey int
const userIDKey ctxKey = 0
func authAs(id int) context.Context {
return context.WithValue(context.Background(), userIDKey, id)
}
func TestGetInvoiceRejectsOtherUsers(t *testing.T) {
s := newTestServer(t) // seeds alice (id 1) and bob (id 2); bob owns invoice 42
req := httptest.NewRequest(http.MethodGet, "/api/invoices/42", nil)
req = req.WithContext(authAs(1)) // alice asks for bob's invoice
rec := httptest.NewRecorder()
s.getInvoice(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("cross-user read got %d, want 404", rec.Code)
}
}
The high-leverage convention is a rule for the whole team: no new endpoint ships without a negative authorization test. Mocking the database away defeats the purpose here — the scoping lives in the SQL, so the test should run against a real query surface, even if it is a scratch database in CI. Framework-level authorization tests (a table of endpoint × method × actor-role combinations) catch the function-level gaps: DELETE endpoints reachable by ordinary users, admin routes with no role check, write paths missing CSRF protections.
Centralize the Decision
Scattered inline checks rot. A durable pattern is middleware or a load helper that resolves the requested resource once, then delegates to a single authorize(actor, action, resource) function for the policy decision. One function means one place to review, one place to log denials, and one place to unit-test the policy matrix. The OWASP ASVS authorization requirements (V8 in version 5.0) give you a checklist to test against, covering exactly the distinctions this post has touched: object-level, function-level, and layer-level authorization. The definitions for individual weaknesses are in the CWE catalog and the A01 entry itself.
Two operational habits round it out. First, deny by default everywhere: route registration should require an explicit access policy, and a route without one should fail startup or lint, not silently serve. Second, alert on enumeration: bursts of 404/403 responses for one identity across a resource-ID space are the signature of an active IDOR scan, and they are cheap to detect in access logs.
Wrapping Up
Broken access control tops the OWASP Top 10:2025 because it is the one vulnerability class that every endpoint can independently reintroduce. Authentication is an architecture problem you solve once; authorization is a discipline you apply to every object reference your API accepts. Scope queries by ownership, protect destructive methods as carefully as reads, require a negative authorization test per endpoint, and centralize the policy decision so “did anyone check?” is never a question you have to ask after the fact.