CWE-862: Missing Authorization - Go
Overview
Go has no single dominant web framework, so Missing Authorization commonly appears in one of two shapes: a handler that reads an authenticated user out of context.Context but never checks that user's role or resource ownership before performing the operation, or a new route registered directly on the mux without the shared authorization middleware wrapping it. Authorization logic here is usually hand-written rather than framework-provided, so the fix is to centralize it into a reusable middleware and package rather than duplicating checks per handler. For anything beyond simple role checks, a policy engine such as Casbin or Open Policy Agent (OPA) is worth adopting rather than growing an ad hoc rule system.
Common Vulnerable Patterns
Handler Checks Authentication But Not Role
// VULNERABLE - confirms the caller is authenticated, but not their role
func RefundOrderHandler(orders OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
_, ok := UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
id := r.PathValue("id")
orders.Refund(r.Context(), id) // any authenticated user can call this
w.WriteHeader(http.StatusNoContent)
}
}
// Attack: any authenticated user sends POST /orders/500/refund
// Result: the refund executes with no role check at all
Why this is vulnerable: UserFromContext confirms a user is authenticated, but the handler never checks that user's role or permission before calling orders.Refund - any logged-in caller can trigger the action.
Route Registered Outside the Authorization Middleware
// VULNERABLE - registered directly on the mux, bypassing RequireRole
mux.HandleFunc("/orders/{id}/cancel", CancelOrderHandler(orders))
// sibling routes are wrapped with RequireRole("staff"), this one is not
// Attack: any authenticated user calls the unwrapped route directly
// Result: the route runs without the role check applied elsewhere
Why this is vulnerable: The route bypasses the shared RequireRole wrapper that comparable routes use, so it has no role check even though the handler itself might expect one to have already run.
Secure Patterns
Role-Based Middleware
// SECURE - shared middleware enforces role-based authorization before the handler runs
func RequireRole(role string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if !user.HasRole(role) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next(w, r)
}
}
mux.HandleFunc("/orders/{id}/refund", RequireRole("staff", RefundOrderHandler(orders)))
Why this works: The role check is centralized in RequireRole and applied at route registration, so the requirement is visible directly in the routing table - a new route wrapped the same way inherits the same check, and a route that forgot the wrapper is easy to spot by inspecting the mux setup.
Resource-Ownership Check Inside the Handler
// SECURE - ownership is part of the lookup, so there is one way to fail
func CancelOrderHandler(orders OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// FindForOwner adds "AND owner_id = ?" to the query rather than
// filtering the row after loading it
order, err := orders.FindForOwner(r.Context(), r.PathValue("id"), user.ID)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
if err := orders.Cancel(r.Context(), order.ID); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
Why this works: The caller's ID is a term in the query rather than a comparison applied to the answer, so the path parameter alone never determines access - an attacker who supplies another user's order ID gets no row back, regardless of what role they hold.
Folding ownership into the lookup also leaves one failure path instead of two. The shape to avoid is the one that reads as more careful: orders.Find(ctx, id) returning 404 when the row is missing, then http.StatusForbidden when order.OwnerID != user.ID. That splits one decision across two responses and makes the pair an existence oracle - 403 confirms the ID is real, 404 confirms it is not, and an attacker walks the ID space reading which is which. Both denials have to leave by the same door, and scoping the query makes that automatic rather than a rule the next handler has to remember.
A role check that does not name a resource is different and should still answer 403: RequireRole above refuses a caller who has no business on the route at all, and that refusal discloses nothing about what exists.
Policy-Based Authorization With Casbin
// SECURE - delegate role/resource decisions to a policy engine instead of scattered if-statements
func RequirePermission(enforcer *casbin.Enforcer, action string) func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
allowed, err := enforcer.Enforce(user.Role, r.URL.Path, action)
if err != nil || !allowed {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next(w, r)
}
}
}
Why this works: Casbin evaluates the (role, resource, action) triple against a centrally defined policy model instead of each handler encoding its own rule, so the policy is unit-testable independently of any handler and which roles can reach which routes can be audited in one place.
Framework-Specific Guidance
chi / gorilla/mux Route Groups
// SECURE - chi middleware wraps http.Handler, so RequireRole above needs restating
// in that shape before .Use() will take it
func RequireRoleMiddleware(role string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if !user.HasRole(role) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
// SECURE - apply the middleware to a route group so every route inside inherits it
r := chi.NewRouter()
r.Route("/orders", func(orders chi.Router) {
orders.Use(RequireRoleMiddleware("staff"))
orders.Post("/{id}/refund", RefundOrderHandler(store))
orders.Post("/{id}/cancel", CancelOrderHandler(store))
})
Why this works: Grouping routes under r.Route and attaching the authorization middleware with .Use once means every route added inside the group is covered automatically, closing the gap where a new route is registered outside the group and misses the check.
chi.Router.Use takes func(http.Handler) http.Handler, so the RequireRole helper further up - which wraps an http.HandlerFunc and is shaped for mux.HandleFunc - will not compile here. It is the same check either way; only the wrapper type differs. Keep one of the two shapes in a project rather than both, because two spellings of the same rule is how one of them ends up out of date.
gRPC Interceptors
For gRPC services, apply authorization as a unary or stream server interceptor (grpc.UnaryInterceptor) rather than checking inside each service method - this mirrors the HTTP middleware pattern and ensures new RPC methods are covered by default rather than needing an inline check added per method.
Testing
- Normal: call the handler as a user holding the correct role and owning the target resource; confirm success.
- Boundary: request a resource owned by another user, then request an ID that does not exist, and confirm the two responses are identical - same status and same body. Under the scoped-lookup pattern above both are 404. A 403 for one and a 404 for the other is an existence oracle whichever way round they are.
- Malicious: build the request with
httptest.NewRequestand send it through the router -mux.ServeHTTP(rec, req), nothandler(rec, req)- as an authenticated user with no role; confirm 403. Routing it through the mux is the point of the test: the role check lives inRequireRoleat registration, so calling the handler function directly bypasses the control being tested and proves nothing. It would also pass for a route someone forgot to wrap, which is the defect this page opens with. - Unit test authorization functions (
RequireRole, ownership comparisons, Casbin policy enforcement) independent of HTTP, since they are the reusable core of the check. That is the place for direct calls - a table test over roles against the wrapper is worth more than the same assertion repeated through every route. - Assert the routing table itself if the project has more than a handful of routes: walk
chi.Walkor your own registration list and fail on any route under a protected prefix whose chain does not include the authorization middleware. This is the only check that catches the route nobody wrapped, because that route has no test of its own to fail. - Re-run any SAST/DAST scan that reported the finding to confirm it no longer triggers.
Common Pitfalls
- Route registered outside the middleware group: Adding a new route directly on the mux instead of inside the route group or
.Use()chain that applies the shared authorization middleware, so it silently misses the check every sibling route has. - Role check with no ownership comparison: Verifying
user.HasRole("staff")on a handler that loads a resource by path parameter, without comparing the resource's owner field to the caller - this allows any staff user to act on any record, not just ones they should have access to. 404for a missing record and403for someone else's: The pair is an existence oracle -403tells the caller the ID is real. On a handler that takes a resource ID, both denials need the same status and the same body.- Ignoring the middleware's error path: Writing a
RequireRolewrapper that callsnext(w, r)on any error from the role lookup instead of denying by default, so a database error or missing claim silently grants access instead of blocking it. - Encoding authorization rules ad hoc per handler: Growing a separate set of role checks in every handler instead of centralizing rules in one package or policy engine, which makes it easy for one handler's checks to drift out of sync with the rest.
Dependencies and Installation
Simple role and ownership checks need no third-party package - a middleware function and a comparison against a loaded resource are enough. For policy-based authorization beyond a handful of roles, add github.com/casbin/casbin/v2 (go get github.com/casbin/casbin/v2) and define a policy model file, or run Open Policy Agent as a sidecar and query it from Go handlers via its REST or Go SDK.