Cybersecurity

How to find and fix broken access control bugs in web apps

Learn how broken access control bugs happen, how to fix them with server-side authorization checks, and how to test the repair with practical code.

4 min read / websecurityfordevelopers

How to find and fix broken access control bugs in web apps

Broken access control is one of the most common security bugs because the application looks like it is checking permissions while the real decision is happening in the wrong place. A button may be hidden in the UI, a route may look private, or an API request may include a user id that feels trustworthy. The bug appears when the server accepts that surface-level signal instead of asking a harder question: is this authenticated user allowed to touch this exact resource?

That distinction matters for anyone reading websecurityfordevelopers because security work is not only about knowing payloads. It is about learning how to reason from a symptom to a root cause. A tester sees an object id, changes it, and gets another user's record. A developer sees the same report and has to turn it into a durable fix. The useful lesson is the same on both sides: every sensitive action needs an authorization decision close to the data it protects.

What the bug looks like

A typical broken access control bug starts with a request that trusts client-controlled input. For example, the app might load a profile by id and assume that a logged-in user should be allowed to read any profile id they request. The endpoint may be behind authentication, but authentication only proves who the user is. Authorization decides what that user may access. Mixing those two ideas is the mistake.

// Vulnerable: any logged-in user can request any profile id.
app.get("/api/profiles/:id", requireUser, async (req, res) => {
  const profile = await db.profile.findUnique({
    where: { id: req.params.id },
  });

  if (!profile) return res.status(404).json({ error: "not found" });
  res.json(profile);
});

The dangerous part is not the database query by itself. The dangerous part is that the query never connects the requested profile to the authenticated user. A browser UI might only show the current user's profile link, but an attacker does not have to use your UI. They can replay the request, change the id, and ask the server directly.

How to rectify it

The fix is to make authorization part of the server-side query or an explicit policy check before returning data. If a normal user may only read their own profile, bind the query to req.user.id. If managers may read team profiles, express that rule clearly in a policy function and test it. The important move is to stop trusting the route parameter alone.

// Safer: the requested profile must belong to the authenticated user.
app.get("/api/profiles/:id", requireUser, async (req, res) => {
  const profile = await db.profile.findFirst({
    where: {
      id: req.params.id,
      ownerId: req.user.id,
    },
  });

  if (!profile) return res.status(404).json({ error: "not found" });
  res.json(profile);
});

For roles and teams, keep the same principle but move it into a reusable guard. The guard should receive the authenticated user and the resource, then return a simple allow or deny decision. That makes the rule easier to audit than scattered if statements across controllers.

function canReadProject(user: User, project: Project) {
  if (project.ownerId === user.id) return true;
  if (user.role === "admin") return true;
  return project.memberIds.includes(user.id);
}

app.get("/api/projects/:id", requireUser, async (req, res) => {
  const project = await db.project.findUnique({ where: { id: req.params.id } });
  if (!project || !canReadProject(req.user, project)) {
    return res.status(404).json({ error: "not found" });
  }
  res.json(project);
});

How to test the fix

Test the failure case, not only the happy path. Create two users, create a resource owned by the first user, then call the endpoint as the second user. The test should prove that the second user cannot read or modify the first user's data. It is also wise to return 404 instead of 403 for private objects when revealing that an id exists would leak information.

it("does not allow a user to read another user's profile", async () => {
  const alice = await makeUser();
  const bob = await makeUser();
  const profile = await makeProfile({ ownerId: alice.id });

  const res = await request(app)
    .get(`/api/profiles/${profile.id}`)
    .set("Authorization", tokenFor(bob));

  expect(res.status).toBe(404);
});

What to ask next

The best next step is to read the relevant security book with a concrete question in mind: where does this application trust the client, and where should the server make the decision instead? You can open websecurityfordevelopers in Vidyora and ask for related access-control examples, testing checklists, and explanations from the book. That is the kind of article-to-book path we want: solve the immediate search problem first, then let the reader go deeper with the source.

web securitybug bountyaccess controlsecure codingCybersecurity