CWE-863: Incorrect Authorization - Go
Overview
Go has no framework-enforced authorization layer, so every handler is responsible for calling an explicit check. Incorrect Authorization in Go handlers typically appears as a denylist role comparison that refuses named roles and admits the rest, a check performed in one handler but forgotten in a sibling handler for the same resource (checked on GET /orders/{id} but not DELETE /orders/{id}), a role read from a client-supplied header or JWT claim without re-verifying it, or middleware registered in the wrong order so the authorization check runs before identity has been established. Because there is no shared attribute or decorator forcing consistency, the fix must centralize authorization in one function per resource and call it from every handler that touches that resource.
Common Vulnerable Patterns
Denylist Role Comparison
// VULNERABLE - denylist fails open on any role value not explicitly excluded
var blockedRoles = map[string]bool{"guest": true, "viewer": true}
func deleteOrderHandler(w http.ResponseWriter, r *http.Request) {
role := r.Header.Get("X-User-Role")
if blockedRoles[role] {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// Every other role reaches this line: "support" (added after this check
// was written), "Guest" (case mismatch against the blocked value) and ""
// (the header absent entirely).
orderID := r.PathValue("id")
deleteOrder(orderID)
w.WriteHeader(http.StatusNoContent)
}
// Attack: send X-User-Role: support, or omit the header entirely
// Result: neither value is in blockedRoles, so the delete proceeds with no
// role actually verified
Why this is vulnerable: the comparison names the one role that is refused and admits everything else, so it is wrong by default for every value nobody thought to consider - and roles are added by people who are not reading this handler. Map lookup on a string key is exact, so "Guest" is a different role from "guest" as far as this line is concerned, and r.Header.Get() returns "" rather than an error for a header that is absent, which is in no list and therefore allowed. An allowlist refuses all three without having to anticipate any of them.
Reading the role from a raw request header compounds the problem: nothing verifies the header came from a trusted source, so even with the comparison written the right way round, the caller still picks their own role by setting X-User-Role.
Resource-Type Check Without Ownership
// VULNERABLE - confirms the order exists, never that the caller owns it
func updateOrderHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := verifiedUserID(r)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
order, err := loadOrder(r.PathValue("id"))
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
// order was found, but nothing compares order.OwnerID to userID
applyUpdate(order, r)
w.WriteHeader(http.StatusNoContent)
}
// Attack: an authenticated low-privilege user requests PUT /orders/{someoneElsesId}
// Result: the update succeeds because only authentication was verified,
// not ownership of this specific order
Why this is vulnerable: Authentication middleware establishes who the caller is; nothing here compares that identity to the record the path selects, so any signed-in account reaches any row. The type of thing being touched is checked - it is an order - and the instance never is.
Go makes this easy to miss because the check that is present looks structural. Middleware wraps the handler and returns early on failure, so the handler body reads as the already-authorized path; the ownership comparison has to live inside the handler where the record is loaded, and there is nothing in the wrapping to suggest it is absent.
Check Missing on a Sibling Handler
// VULNERABLE - the ownership check exists on the single-resource route but
// was never added to the bulk route that reaches the same data
func getOrderHandler(w http.ResponseWriter, r *http.Request) {
userID, _ := verifiedUserID(r)
order, _ := loadOrder(r.PathValue("id"))
if order.OwnerID != userID {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
writeJSON(w, order)
}
func bulkExportHandler(w http.ResponseWriter, r *http.Request) {
// Added later; no ownership filtering applied at all.
var ids []string
json.NewDecoder(r.Body).Decode(&ids)
orders := loadOrders(ids)
writeJSON(w, orders)
}
Why this is vulnerable: Two handlers reach the same rows and only one is wrapped, so the authorization the data actually has is the weaker of the two. With net/http the registration and the handler are separate lines, often in separate files, which is exactly the arrangement in which a route gets added to the mux without its middleware.
Fixing the reported handler is not sufficient here, and the search that finds the rest is by data rather than by URL: every function touching the same table or repository, not every path matching the pattern in the finding. An http.ServeMux with per-route wrapping has no mechanism that would fail loudly for the one that was missed - the alternative is to apply the check where the data is loaded, so it cannot be routed around.
Secure Patterns
Shared Allowlist and Ownership Check
// SECURE - shared allowlist + ownership check applied before handler logic runs
package main
import (
"encoding/json"
"net/http"
)
// allowedRoles is an explicit allowlist; any role not listed is denied.
var allowedRoles = map[string]bool{
"admin": true,
"editor": true,
}
type Order struct {
ID string
OwnerID string
}
func loadOrder(id string) (*Order, error) {
// Loads the order from the database; omitted for brevity.
return &Order{ID: id, OwnerID: "user-123"}, nil
}
// resolveVerifiedIdentity verifies the session cookie or JWT signature and
// returns trusted claims; it never reads role/userID from an unverified
// header or request body.
func resolveVerifiedIdentity(r *http.Request) (userID, role string, ok bool) {
// Signature/session verification happens here.
return "user-123", "editor", true
}
// authorizeOrderAccess is the single place ownership and role logic lives.
// Every handler that touches Order data calls this instead of repeating
// inline checks that can drift out of sync.
func authorizeOrderAccess(r *http.Request, order *Order) bool {
userID, role, ok := resolveVerifiedIdentity(r)
if !ok || !allowedRoles[role] {
return false
}
if role == "admin" {
return true
}
return order.OwnerID == userID
}
func deleteOrderHandler(w http.ResponseWriter, r *http.Request) {
orderID := r.PathValue("id")
order, err := loadOrder(orderID)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
if !authorizeOrderAccess(r, order) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// Delete logic runs only after both role and ownership are confirmed.
w.WriteHeader(http.StatusNoContent)
}
func bulkExportHandler(w http.ResponseWriter, r *http.Request) {
var ids []string
if err := json.NewDecoder(r.Body).Decode(&ids); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
var authorized []*Order
for _, id := range ids {
order, err := loadOrder(id)
if err != nil {
continue
}
// The same shared function is called here, so the bulk endpoint
// cannot drift out of sync with the single-resource endpoint.
if authorizeOrderAccess(r, order) {
authorized = append(authorized, order)
}
}
json.NewEncoder(w).Encode(authorized)
}
Why this works: authorizeOrderAccess is the single implementation of the authorization decision, called from every handler that touches Order data, so a fix made once cannot be silently absent from a sibling or later-added handler. The role comparison is an explicit allowlist (allowedRoles[role]): a role that is not in the map reads as false, so a value nobody anticipated is denied without having to be enumerated. Identity is resolved through resolveVerifiedIdentity, which verifies the session or JWT signature server-side, so a request cannot set a header to claim a role or user ID.
Middleware Ordering for Authorization
// SECURE - authentication middleware runs and populates context before
// any authorization check can run; ordering is explicit, not assumed
func requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID, role, ok := resolveVerifiedIdentity(r)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), ctxUserID, userID)
ctx = context.WithValue(ctx, ctxRole, role)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func main() {
mux := http.NewServeMux()
mux.Handle("DELETE /orders/{id}", requireAuth(http.HandlerFunc(deleteOrderHandler)))
mux.Handle("POST /orders/bulk-export", requireAuth(http.HandlerFunc(bulkExportHandler)))
http.ListenAndServe(":8080", mux)
}
Why this works: wrapping every route for the resource with the same requireAuth middleware, registered at the routing table rather than inline per handler, keeps every route's authentication status in one block, where a registration missing the wrapper is visible beside the ones that have it. Because the middleware populates identity in the request context before any handler logic runs, authorizeOrderAccess always has verified data to compare against, not a value read directly from client-controlled headers.
Testing
- Role boundary: table-test
authorizeOrderAccess(or the equivalent shared function) with a role value not inallowedRoles, an empty string, and a role with different casing, confirming each is denied. - Cross-owner access: call each handler for the resource (
GET,PUT,DELETE, and the bulk route) as a non-owner and confirm every one independently returns403. - Header spoofing: send the request with a forged
X-User-Roleor an unsigned JWT and confirmresolveVerifiedIdentityrejects it rather than trusting the claim. - Route coverage: grep the codebase for every
mux.Handle/mux.HandleFuncregistration touching the resource and confirm each one is wrapped with the same authorization middleware or calls the same shared function. - Use
httptest.NewRequest/httptest.NewRecorderto exercise handlers directly, bypassing the UI, so the test reaches the same code path an attacker would use.
Common Pitfalls
- Repeating inline checks per handler instead of centralizing them: Copy-pasting the same
if role != "admin"block into each new handler means a fix to one copy does not propagate to the others - extract a shared function or middleware instead. - Trusting a role or user ID from a request header or body:
r.Header.Get("X-User-Role")or a JSON body field can be set to anything by the caller; only a verified session or signature-checked JWT claim is trustworthy. - Registering a new route without the shared authorization middleware: Because Go's
net/httphas no default-secure behavior, a route added directly tomuxwithout wrapping it in the existing middleware is unauthenticated and unauthorized by default, not by exception. - Caching a resolved role across requests: Re-verify the token or session on every request rather than reusing a role resolved earlier in a long-lived connection or goroutine, since permissions can change between requests.