Hiding the Button Is Not Security
If the only thing stopping a user from deleting the project is that they can't see the Delete button, nothing is stopping them. Why authorization must live on the server.
A ticket comes in: "Viewers should not be able to delete projects." A developer picks it up, finds the project page component, and wraps the delete button in a permission check:
{currentUser.role === "admin" && (
<DeleteProjectButton projectId={project.id} />
)}
Ticket closed. QA confirms: log in as a viewer, no delete button. Ship it.
Except nothing about deletion actually changed. The API endpoint that performs the delete is exactly as reachable as it was yesterday. A viewer who opens their browser's dev tools — or just replays a request they saw an admin make — sends DELETE /api/projects/42 and the project is gone. The UI check didn't restrict the capability. It restricted the advertisement of the capability. Those are profoundly different things, and confusing them is one of the most common authorization failures I see in review.
Your frontend is a suggestion
Here's the mental shift that makes this class of bug obvious forever after: the frontend is not part of your security boundary. It runs on hardware you don't control, executing code the user can read, modify, and bypass entirely. Every React conditional, every disabled button, every hidden menu item, every client-side route guard — from the server's perspective, none of it exists. The only thing your server actually knows is that an HTTP request arrived bearing some credentials.
An attacker doesn't use your UI. They use curl, or the network tab, or an intercepting proxy. Your beautiful permission-aware component tree is, to them, documentation — and helpfully, it often documents exactly which endpoints you considered sensitive enough to hide.
I want to be clear that UI permission checks are good. Hiding actions users can't perform is good UX; nobody should see buttons that will just error. The rule isn't "don't check on the frontend." The rule is: the frontend check is a courtesy; the backend check is the security. You need both, and only one of them counts when things go wrong.
The failure modes, in the order I meet them
The check that only exists in the component
The scenario above. The tell in code review is a diff that changes authorization behavior while touching only frontend files. That's not automatically wrong — maybe the backend check already exists — but it's automatically a question: "Where does the server enforce this?" If the answer involves scrolling through the diff looking hopeful, we have a finding.
The endpoint that trusts the client's claim
A step sneakier: the backend does check, but it checks something the client asserted.
// The client said they're an admin. Cool. Cool cool cool.
app.delete("/api/projects/:id", async (req, res) => {
if (req.body.role !== "admin") return res.sendStatus(403);
await deleteProject(req.params.id);
res.sendStatus(204);
});
Role, user ID, org ID, plan tier — if it arrived in the request body, a header the client sets, or an unsigned cookie, it's not a fact, it's a claim. Authorization inputs must come from server-side truth: the session, a verified token's claims, a database row. In review I trace every value used in a permission decision back to its origin, and the origin has to be something the client can't write.
The sibling endpoint that forgot
The team locks down DELETE /api/projects/:id properly. But the bulk endpoint — POST /api/projects/bulk with {"action": "delete", "ids": [...]} — was added later, by someone else, and enforces nothing. Or the GraphQL mutation. Or the v2 route that coexists with v1. Capability is what matters, and capability is the union of every path that reaches the dangerous operation. When authorization checks are copy-pasted per-endpoint, the union always develops holes.
Centralize the rules, or lose track of them
That last failure mode points at the structural fix: permission logic scattered across controllers is permission logic you can't audit. The pattern that survives growth is a single place where the question "can this actor do this action to this resource?" gets answered — policy objects in Rails (Pundit and friends), middleware plus a policy layer in Express, decorators in Django, whatever your stack's idiom is.
class ProjectPolicy
def initialize(user, project)
@user, @project = user, project
end
def destroy?
@user.admin_of?(@project.organization)
end
end
Two properties make centralization pay for itself. First, every endpoint that touches a project calls the same policy, so a new bulk endpoint can't quietly invent weaker rules — it either calls destroy? or it visibly doesn't, and "visibly doesn't" is easy to catch in review. Second, the rules become testable as rules: a policy spec asserting that a viewer can't destroy is worth a dozen scattered controller tests, and it survives refactors that move endpoints around.
If your framework supports it, go one step further and make authorization opt-out instead of opt-in — a hook that raises in development when an action completes without any policy having been consulted. Forgetting a check should be loud, not silent.
The generated-code angle
Authorization is the thing AI assistants omit most reliably, and it's worth understanding why: an assistant asked to "add an endpoint to update project settings" produces exactly that — a working endpoint. Authorization wasn't in the prompt, isn't in the visible context, and doesn't show up in most training examples, so it doesn't show up in the output. What you get is professionally structured, validated, error-handled code with no permission check whatsoever. There's no incorrect line to catch — the vulnerability is a missing paragraph.
This is why my first question on any generated endpoint isn't about the code that's there. It's "who is allowed to call this, and where is that enforced?" If the diff can't answer, the diff isn't done — no matter how clean it looks.
What I verify before approving an endpoint
- Can I hit this endpoint with
curlas the wrong user and get refused? Not "would the UI let me" — would the server. - Does every input to the permission decision come from server-side truth (session, verified token, database), never from the request?
- Does this go through the central policy layer, or does it hand-roll its own check — and if hand-rolled, why?
- Do all paths to the capability enforce the same rule — bulk routes, GraphQL, admin variants, v1 and v2?
- Is there a test where an unauthorized user attempts the action and the server says no?
Hiding the button is hospitality. The backend check is the lock. Review every permissions diff by asking where the lock is, and never accept "the button's gone" as an answer.