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.
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.
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
});@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())@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, .
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);
});@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())@GetMapping("/api/invoices/{id}")
public Invoice get(@PathVariable long id, @AuthenticationPrincipal User user) {
return repo.findByIdAndOwnerId(id, user.getId())
.orElseThrow(NotFoundException::new);
}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.
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 == 401test("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.
connections on the map
- A06 Insecure Designdefines the rules forA01 Broken Access Control
- A01 Broken Access Controlneeds a trusted identity fromA07 Authentication Failures
- A05 Injectioncan bypassA01 Broken Access Control
- A09 Security Logging and Alerting Failuresdetects abuse ofA01 Broken Access Control
updated on 14/10/2026 · sources to go deeper · report an error
in other domains
- Web and API security testingoffsechow to find what the other teaches you to fix
- IAM - identity and accesscloudsecthe same least-privilege rule, in the cloud
Unlocks
test what you learned
Loading questions…