Broken Access Control
Broken access control sits at the top of the OWASP Top 10 for good reason, and it’s the vulnerability class I find most fascinating to research. It’s the most common serious vulnerability in modern web applications, and it’s almost entirely a logic problem, no amount of input sanitization or encryption fixes it. The application simply fails to verify that the authenticated user is authorized to perform the requested action on the requested resource. In this post, I want to walk through the patterns that show up across Python, Java, and Go, from the IDOR that any pentester would find in minutes to the subtle authorization gaps that can survive months of code review.
Why Access Control Breaks
Access control bugs are fundamentally different from injection or cryptographic failures, and that’s what makes them so tricky to study. Those vulnerability classes have mechanical fixes, use parameterized queries, use AES-256. Access control requires the developer to correctly model who can do what to which resource, and then enforce that model on every single endpoint. Miss one endpoint, and you have a vulnerability. Reading through public vulnerability disclosures, you see codebases with hundreds of endpoints where 99% had proper authorization, but that one missing check was all it took.
The common failure modes are:
- Insecure Direct Object References (IDOR): The application uses a user-supplied identifier (user ID, document ID) to fetch a resource without verifying the requester owns or has access to it.
- Missing function-level access control: An endpoint exists for admin functionality but doesn’t verify the caller is an admin.
- Client-side role enforcement: The server trusts a role or permission value sent by the client instead of deriving it from the authenticated session.
- Excessive data exposure: An API returns more fields than the caller is authorized to see (e.g., returning SSN and salary in a profile response).
The Easy-to-Spot Version
Python: IDOR on User Profile
@app.route("/api/users/<int:user_id>", methods=["GET"])
def get_user_profile(user_id):
session = get_current_session()
if not session:
return jsonify({"error": "Authentication required"}), 401
user = USERS.get(user_id)
if not user:
return jsonify({"error": "User not found"}), 404
return jsonify(user)
The endpoint checks authentication (is the caller logged in?) but not authorization (is the caller allowed to view this specific user’s profile?). Any authenticated user can request any other user’s profile by changing the user_id in the URL. If the user object contains sensitive fields like SSN, salary, or internal notes, this is a data breach. This is one of the first things worth testing for in any API, it’s a well-documented pattern that still shows up regularly.
The fix seems simple, check session["user_id"] == user_id, but that breaks admin use cases. The real fix requires a role-aware authorization check, and that’s where things get interesting.
Go: Unrestricted User Profile Access
func getUserProfile(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
return
}
mu.RLock()
user := users[id]
mu.RUnlock()
if user == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
c.JSON(http.StatusOK, user)
}
This Go endpoint doesn’t even check authentication. Any caller, authenticated or not, can fetch any user’s complete profile including SSN and salary fields. The User struct serializes every field to JSON by default because all fields have json tags. There’s no field filtering based on the caller’s role. This tends to happen in Go APIs where the developer assumed the endpoint would only be called by internal services, but it was exposed on the public network.
The Hard-to-Spot Version
Python: Role Check on Wrong Attribute
@app.route("/api/admin/users", methods=["GET"])
def list_all_users():
session = get_current_session()
if not session:
return jsonify({"error": "Authentication required"}), 401
requesting_user = USERS.get(session["user_id"])
if not requesting_user or requesting_user.get("role") != "admin":
return jsonify({"error": "Admin access required"}), 403
return jsonify({"users": list(USERS.values())})
This looks correct, it checks the session, looks up the user, verifies the admin role. But what if the role field on the user object can be modified through another endpoint? If there’s a profile update endpoint that accepts arbitrary fields:
@app.route("/api/users/<int:user_id>", methods=["PUT"])
def update_user(user_id):
session = get_current_session()
if not session:
return jsonify({"error": "Authentication required"}), 401
data = request.get_json()
user = USERS.get(user_id)
if not user:
return jsonify({"error": "User not found"}), 404
for key, value in data.items():
user[key] = value
return jsonify({"message": "User updated"})
The for key, value in data.items() loop updates any field the caller sends, including role. A regular user sends PUT /api/users/3 {"role": "admin"} and escalates their privileges. The admin check on the listing endpoint is correct in isolation, but the update endpoint undermines it. This is a mass assignment / privilege escalation chain that requires reviewing multiple endpoints together. What makes it so dangerous is that each endpoint looks fine on its own, you only see the vulnerability when you consider them as a system. I’ve run into this pattern in code reviews, and it’s a good reminder to always think about how endpoints interact.
Go: Client-Controlled Role Header
func listAllUsers(c *gin.Context) {
sess := getSession(c)
if sess == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
clientRole := c.GetHeader("X-User-Role")
if clientRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
mu.RLock()
userList := make([]*User, 0, len(users))
for _, u := range users {
userList = append(userList, u)
}
mu.RUnlock()
c.JSON(http.StatusOK, gin.H{"users": userList})
}
The endpoint authenticates the user via the session token, then checks authorization via the X-User-Role header. The session is server-controlled and trustworthy. The header is client-controlled and trivially spoofable. An attacker adds X-User-Role: admin to their request and bypasses the authorization check entirely.
This pattern is surprisingly common in microservice architectures. The idea is that an API gateway is supposed to set role headers based on the authenticated token. But if the backend service is directly accessible (or the gateway doesn’t strip client-supplied headers), the authorization is meaningless. The question worth asking is: “What happens if someone hits this service directly, bypassing the gateway?” If the answer is “they get admin access,” there’s a problem.
Java: Missing Authorization on State-Changing Operations
@PutMapping("/tickets/{id}")
public ResponseEntity<?> updateTicket(
@PathVariable int id,
@RequestBody Map<String, Object> body,
@RequestHeader("Authorization") String auth) {
Session session = getSession(auth);
if (session == null) {
return ResponseEntity.status(401).body(Map.of("error", "Auth required"));
}
Ticket ticket = tickets.get(id);
if (ticket == null) {
return ResponseEntity.status(404).body(Map.of("error", "Not found"));
}
// Any authenticated user can update any ticket
if (body.containsKey("title")) ticket.setTitle((String) body.get("title"));
if (body.containsKey("status")) ticket.setStatus((String) body.get("status"));
if (body.containsKey("priority")) ticket.setPriority((String) body.get("priority"));
return ResponseEntity.ok(Map.of("message", "Updated", "ticket", ticket));
}
Authentication is checked. The ticket exists. But there’s no check that the authenticated user is the ticket’s assignee, creator, or an admin. A customer can modify another customer’s ticket, change its priority, or close it. The developer likely assumed that the front-end would only show users their own tickets, but the API is the security boundary, not the UI.
This is harder to spot because the endpoint does have an auth check. A reviewer scanning for “missing authentication” would pass right over it. The missing piece is authorization, which requires understanding the business rules about who can modify tickets. From what I’ve seen in public disclosures, this is one of the most common forms of broken access control in mature applications: authentication is present, but authorization is incomplete or missing entirely.
The Debug Endpoint Problem
func debugConfig(c *gin.Context) {
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
dbURL = "postgres://admin:s3cret@db:5432/ticketdb"
}
secret := os.Getenv("SESSION_SECRET")
if secret == "" {
secret = "helpdesk-session-secret-key"
}
c.JSON(http.StatusOK, gin.H{
"databaseUrl": dbURL,
"sessionSecret": secret,
"apiKeys": gin.H{
"twilio": getEnvDefault("TWILIO_KEY", "AC_twilio_key_123"),
"mailgun": getEnvDefault("MAILGUN_KEY", "mg_key_456"),
},
})
}
No authentication, no authorization. This endpoint exposes database credentials, session secrets, and third-party API keys to anyone who knows the URL. Debug endpoints are often added during development and forgotten. They don’t appear in the application’s UI, so they’re invisible to manual testers who only click through the interface. Automated scanners that enumerate common paths (/debug, /config, /env, /actuator) will find them. Reading through bug bounty reports, debug endpoints in production that expose everything from database passwords to payment processor keys come up more often than you’d expect. It’s one of those findings that makes your heart skip a beat.
Detection Strategies
SAST Limitations
Access control bugs are notoriously difficult for SAST tools to detect, and I think this is one of the biggest gaps in automated security testing. The reason is that they require understanding business logic. A SAST tool can identify that an endpoint lacks an authentication check, but it cannot determine whether a particular user should be allowed to access a particular resource. That’s a semantic question, not a syntactic one.
What SAST can catch:
- Endpoints with no authentication middleware or decorator
- Debug/admin endpoints without access restrictions
- Client-controlled headers used in authorization decisions
What SAST cannot catch:
- IDOR where authentication exists but authorization is missing
- Privilege escalation through mass assignment
- Inconsistent authorization across related endpoints
Manual Review Strategy
Here’s an approach that works well for reviewing access control issues:
- Map every endpoint and its required authorization level (public, authenticated, owner-only, admin-only)
- Check state-changing operations (PUT, POST, DELETE) more carefully than read operations, missing authorization on writes is higher severity
- Trace object references, when an endpoint takes an ID parameter, verify the code checks that the authenticated user has access to that specific object
- Look for mass assignment, any endpoint that iterates over request body keys and sets object properties is a privilege escalation risk
- Search for debug/diagnostic endpoints, grep for
/debug,/config,/env,/healthpaths and verify they’re protected - Check header trust, any authorization decision based on a request header (not a session/token) is suspect
Doing this review as a table or spreadsheet, endpoint, method, auth level, verified?, is tedious, but it’s the only reliable way to catch these bugs systematically.
Remediation
Implement Authorization at the Resource Level
@app.route("/api/users/<int:user_id>", methods=["GET"])
def get_user_profile(user_id):
session = get_current_session()
if not session:
return jsonify({"error": "Authentication required"}), 401
requesting_user = USERS.get(session["user_id"])
# Owner can see their own full profile
if session["user_id"] == user_id:
return jsonify(USERS[user_id])
# Admin can see any profile
if requesting_user.get("role") == "admin":
return jsonify(USERS[user_id])
# Others get a filtered view
user = USERS.get(user_id)
return jsonify({
"id": user["id"],
"username": user["username"],
"role": user["role"]
})
Prevent Mass Assignment
ALLOWED_UPDATE_FIELDS = {"email", "display_name", "bio"}
@app.route("/api/users/<int:user_id>", methods=["PUT"])
def update_user(user_id):
session = get_current_session()
if not session or session["user_id"] != user_id:
return jsonify({"error": "Forbidden"}), 403
data = request.get_json()
user = USERS.get(user_id)
for key, value in data.items():
if key in ALLOWED_UPDATE_FIELDS:
user[key] = value
return jsonify({"message": "User updated"})
Derive Roles from Server State
Never trust client-supplied role information, always derive the user’s role from the authenticated session or a server-side lookup:
func listAllUsers(c *gin.Context) {
sess := getSession(c)
if sess == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
// Use the role from the server-side session, not a client header
if sess.Role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
// ...
}