Insecure direct object references: the URL was the exploit
Broken access control is the OWASP category with the most real-world reports by a wide margin, and it earns that spot honestly. It's rarely a clever exploit. It's usually a missing if statement. The most common shape of it is the insecure direct object reference, IDOR for short, and the entire attack is often just editing a number in the address bar.
An invoice lookup
1@app.route("/invoices/<int:invoice_id>")2@login_required3def get_invoice(invoice_id):4 invoice = db.query(Invoice).filter_by(id=invoice_id).first()5 return jsonify(invoice.to_dict())
@login_required proves you're logged in. It says nothing about whether invoice_id belongs to you. Your own invoice loads at /invoices/1001. Change the last digit and you're looking at somebody else's, still fully authenticated, still yourself, just reading a record that was never supposed to be yours to read.
Sequential integer IDs make this worse because they're enumerable: 1001, 1002, 1003, every one of them a request away. Switching to UUIDs raises the cost of guessing an ID, which is worth doing, but it doesn't fix the actual bug. If an ID ever leaks, in a shared link, a support ticket, a browser history, the missing check is still missing.
Fixed: scope the query to the authenticated user
1@app.route("/invoices/<int:invoice_id>")2@login_required3def get_invoice(invoice_id):4 invoice = db.query(Invoice).filter_by(5 id=invoice_id, user_id=current_user.id6 ).first()7 if invoice is None:8 abort(404)9 return jsonify(invoice.to_dict())
The query itself now enforces ownership. There's no separate permission check to remember to add, and no way to accidentally return someone else's row, because the database was never asked for one.
Missing function-level access control
The check in the last section covers "is this record yours." A separate, equally easy to forget check covers "are you even allowed to call this action at all," regardless of whose record it touches.
An admin action, guarded by login only
1@app.route("/admin/users/<int:user_id>", methods=["DELETE"])2@login_required3def delete_user(user_id):4 db.query(User).filter_by(id=user_id).delete()5 return "", 204
The route lives under /admin/, which reads like a permission check but isn't one. The only thing @login_required verifies is that a valid session exists. Any logged-in user, admin or not, can send this request directly and it will run.
Not from the admin dashboard, just a terminal
1curl -X DELETE https://app.example/admin/users/42 \2 -H "Authorization: Bearer <any-regular-user-token>"
Authentication and authorization answer two different questions. Authentication is "who are you." Authorization is "what are you allowed to do, now that I know who you are." A decorator or middleware that only handles the first one will happily let a regular account delete another user, because nothing in the request path ever asked the second question.
Fixed: ask both questions
1@app.route("/admin/users/<int:user_id>", methods=["DELETE"])2@login_required3@admin_required4def delete_user(user_id):5 db.query(User).filter_by(id=user_id).delete()6 return "", 204
This class of bug shows up most often on newer, less-visited endpoints: the one internal tool route, the export feature added for one customer, the debug endpoint nobody remembered to remove. Nothing about them looks wrong in a code review that's scanning for logic bugs. They look wrong only if you're specifically checking who's allowed to hit them.
Hiding the button isn't access control
The delete_user endpoint from the last section probably does have a role check somewhere in this app, just in the wrong place.
The admin dashboard, React
1{user.role === "admin" && (2 <button onClick={() => deleteUser(targetId)}>Delete User</button>3)}
This is genuinely correct React. Non-admins never see the button, which is exactly the UX you want, most users should never even be aware this action exists. But it's UX, not enforcement. If the backend route behind deleteUser() is the unfixed version from the last section, hiding the button changes nothing for anyone willing to open their browser's network tab, copy the request, and replay it with a different token. The button was never the boundary. The endpoint was, whether anyone remembered that or not.
A check that lives only in frontend code is a suggestion, not a rule. The frontend is one client of your API among many. Anyone can write a different one, curl, Postman, a five-line script, that simply skips the part you didn't enforce on the server.
None of this means hiding the button is wrong to do. It's good UX and it reduces support tickets from confused users who technically could see an action but shouldn't have. The mistake is treating it as the security control instead of what it actually is: a convenience layered on top of a check that has to live on the server regardless.
Default deny, and putting the check in one place
Three different bugs, same root cause: a check that existed somewhere in the system but not on the specific path that needed it. That pattern doesn't get fixed by being more careful next time. It gets fixed by changing what "careful" even means, so a missing check is the exception a linter or a test catches, not the default state of a new route.
Default deny
Most frameworks default to open: a new route works the moment you define it, and authorization is something you bolt on afterward if you remember to. Flip that assumption and every route is unreachable until something explicitly grants access to it. Forgetting to add a check now fails closed, a 403, instead of failing open, full access to whoever asks.
One function, not one check per handler
Scattering if resource.owner_id != user.id across every handler that touches a resource means fixing this bug once doesn't fix it everywhere, it fixes it in the one place you happened to be looking. Centralizing the check gives you one function to audit, one place to add a new role, one thing to write a test against.
A single authorization function, reused everywhere
1def authorize(user, resource, action="view"):2 if user.is_admin:3 return4 if resource.owner_id != user.id:5 abort(404)67# usage, wherever a handler touches a resource8invoice = get_invoice_or_404(invoice_id)9authorize(current_user, invoice)
403 or 404: whether existence itself is a secret
The function above returns 404, not 403, for a resource that exists but isn't yours. That's deliberate. If "not found" and "found, but not yours" produce different status codes, an attacker can tell which invoice IDs are real just by watching which response they get back, 403 versus 404, without ever seeing the data itself. Returning 404 for both cases makes them indistinguishable from outside the system.
This isn't a universal rule. For a resource where existence isn't sensitive, an admin settings page any employee already knows is there, a clear 403 with a real message is better UX and there's no meaningful secret being protected either way. The question worth asking per resource is simple: does knowing this exists tell an attacker something they shouldn't know. If yes, 404. If not, 403 is fine, and kinder to whoever hits it by mistake.