Skip to content
back to the map
technique
stage: design
3 min read

A01 Broken Access Control

Authentication says who you are; access control says what you may do. When the server forgets to check on every request, changing an id in a URL is all an attacker needs.

Burp Suite (Autorize)OWASP ZAPPostman or curl with two accountsSemgrepCasbin / OPA

What it is

Access control enforces policy so users cannot act outside their intended permissions. It breaks when the server trusts something the client controls: an id in the URL, a hidden field, a role claim, a cookie. The result is reading other people's data, changing it, or reaching admin functions.

:2025 lists it as A01. The two everyday shapes are IDOR (, also called in API security): the request names an object and the server returns it without asking whether the caller owns it; and missing function-level checks: an endpoint exists for admins, but nothing stops a normal user from calling it.

How the attack works

The attacker logs in as a normal user, opens the browser's network tab, and finds a request like GET /api/invoices/1041. They change 1041 to 1042 and read someone else's invoice. If ids are sequential, a ten-line script walks the whole table.

  • Change an id in the path, query string, JSON body or a header, and see if the answer changes owner.
  • Call an admin or internal route directly (/admin/users, DELETE /api/users/7) with a normal session.
  • Send extra fields the form never shows ("role": "admin"): lets the client set what it should not.
  • Hide the button in the UI: the UI is not a control, the server is.

Vulnerable code

Each handler checks that the user is logged in, then loads the object by id alone. Nothing ties the invoice to the caller.

invoices.js (Express + Prisma)javascript
app.get("/api/invoices/:id", requireLogin, async (req, res) => {
  const invoice = await db.invoice.findUnique({ where: { id: Number(req.params.id) } });
  res.json(invoice); // any logged-in user can read any invoice
});
invoices.py (Flask + SQLAlchemy)python
@app.get("/api/invoices/<int:invoice_id>")
@login_required
def get_invoice(invoice_id):
    invoice = Invoice.query.get_or_404(invoice_id)
    return jsonify(invoice.to_dict())
InvoiceController.java (Spring)java
@GetMapping("/api/invoices/{id}")
public Invoice get(@PathVariable long id) {
    return repo.findById(id).orElseThrow(NotFoundException::new);
}

The fix

Put ownership in the query itself, so a row that is not yours is simply not found. Answer the same way for "does not exist" and "is not yours" so the response does not confirm which ids are valid. The rule is the same in every language: authorize on the server, on every request, .

invoices.jsjavascript
app.get("/api/invoices/:id", requireLogin, async (req, res) => {
  const invoice = await db.invoice.findFirst({
    where: { id: Number(req.params.id), ownerId: req.user.id },
  });
  if (!invoice) return res.status(404).end();
  res.json(invoice);
});
invoices.pypython
@app.get("/api/invoices/<int:invoice_id>")
@login_required
def get_invoice(invoice_id):
    invoice = Invoice.query.filter_by(
        id=invoice_id, owner_id=current_user.id
    ).first_or_404()
    return jsonify(invoice.to_dict())
InvoiceController.javajava
@GetMapping("/api/invoices/{id}")
public Invoice get(@PathVariable long id, @AuthenticationPrincipal User user) {
    return repo.findByIdAndOwnerId(id, user.getId())
               .orElseThrow(NotFoundException::new);
}
admin.js (function-level check, deny by default)javascript
const requireRole = (role) => (req, res, next) =>
  req.user?.roles?.includes(role) ? next() : res.status(403).end();

app.delete("/api/users/:id", requireLogin, requireRole("admin"), deleteUser);

Prove the fix

A regression test uses two users. User A creates an invoice; user B asks for it and must get a 404 (or 403), and an anonymous call must get a 401. Run it in CI so a refactor cannot quietly remove the check. The same test shape works in any language and for any object type.

test_invoices_access.py (pytest)python
def test_user_cannot_read_other_users_invoice(client, alice, bob):
    invoice_id = client.post("/api/invoices", json={"total": 10}, auth=alice).json()["id"]

    assert client.get(f"/api/invoices/{invoice_id}", auth=alice).status_code == 200
    assert client.get(f"/api/invoices/{invoice_id}", auth=bob).status_code == 404
    assert client.get(f"/api/invoices/{invoice_id}").status_code == 401
invoices.test.js (supertest)javascript
test("bob cannot read alice's invoice", async () => {
  const { body } = await request(app).post("/api/invoices").set(alice).send({ total: 10 });
  await request(app).get(`/api/invoices/${body.id}`).set(bob).expect(404);
  await request(app).get(`/api/invoices/${body.id}`).expect(401);
});

Checklist

  • Deny by default: a route with no explicit rule is forbidden, not open.
  • Enforce ownership in one shared place (a query helper, a policy engine), not by copy-pasting checks per handler.
  • Do not trust ids, roles or prices that come from the client. Derive identity from the session.
  • Use unpredictable ids where useful (UUIDs), but never as the only control: an unguessable id is not authorization.
  • Log access-control failures and alert on bursts: someone enumerating ids is visible.
  • Test with two accounts on every release. Automated scanners rarely understand who should see what.
OWASP Top 10The best-known reference list of web application security risks (A01 broken access control, A02 cryptographic failures, A03 injection...), revised every few years by the OWASP community. It is an educational map of risk categories, not a tool checklist or a certification.see in glossary →IDORinsecure direct object reference · BOLAThe request names an object (an id) and the server returns it without checking that the caller may see it. The most common form of broken access control, and in APIs it is called broken object level authorization.see in glossary →BOLAbroken object level authorizationAn API accepts an object id and returns the object without checking that the caller may access it. The API form of an IDOR.see in glossary →Mass assignmentAn API binds client-supplied fields onto an object, letting a caller set fields (such as a role) that they should not control.see in glossary →Deny by defaultAccess is forbidden unless a rule explicitly allows it, so a forgotten route fails closed instead of open.see in glossary →

connections on the map

updated on 14/10/2026 · sources to go deeper · report an error

in other domains

Unlocks

test what you learned

Loading questions…