1. Why Authorization Fails in Real APIs
Many teams believe an API is secure once authentication is in place. The token is valid, the session is active, and the request reaches the backend.
But that only proves identity. It does not prove that the caller is allowed to access a specific record, read a sensitive field, or invoke a privileged action.
Authentication answers who the caller is. Authorization must answer what that caller can access, modify, and execute.
OWASP splits authorization failures into multiple categories because APIs do not fail in just one place. A system may protect functions correctly but leak fields. Or it may filter fields correctly while still exposing the wrong object.
2. The OWASP Mapping
OWASP API Security Top 10 2023 separates these authorization failures into three different risks.
| Category | Core Question | OWASP Risk | Typical Failure |
|---|---|---|---|
| BOLA | Can this user access this specific record? | API1:2023 | Changing an ID exposes another user's object |
| BOPLA | Can this user read or modify these fields? | API3:2023 | Sensitive fields are exposed or writable |
| BFLA | Can this user perform this action? | API5:2023 | Low-privilege users invoke admin functions |
3. The Difference, Clearly
These categories are related, but they break at different layers of an authorization model.
| Type | What is being protected? | Attacker changes | Example | Impact |
|---|---|---|---|---|
| BOLA | A record or object | Identifier | /api/invoices/9002 → /api/invoices/9003 | Unauthorized data access |
| BOPLA | Fields inside an object | Payload or response surface | { role: "admin" } | Privilege escalation or data leakage |
| BFLA | An operation or capability | Route, method, or privileged function | POST /api/admin/delete-user | Administrative abuse |
Simple mental model
BOLA asks: which object?
BOPLA asks: which fields?
BFLA asks: which action?
4. BOLA — Broken Object Level Authorization
BOLA happens when the API accepts a client-controlled identifier and uses it to fetch a record without validating whether the caller should have access to that record.
Vulnerable example
routes/invoices.tsapp.get("/api/invoices/:id", authMiddleware, async (req, res) => {
const invoice = await db.invoice.findUnique({
where: { id: req.params.id },
});
if (!invoice) {
return res.status(404).json({ error: "Invoice not found" });
}
return res.json(invoice);
});This code answers only one question: does this invoice exist? It never answers the important one: does this user have access to this invoice?
Secure example
routes/invoices.tsapp.get("/api/invoices/:id", authMiddleware, async (req, res) => {
const invoice = await db.invoice.findFirst({
where: {
id: req.params.id,
ownerId: req.user.id,
},
});
if (!invoice) {
return res.status(404).json({ error: "Invoice not found" });
}
return res.json(invoice);
});Multi-tenant secure example
services/invoice-service.tsconst invoice = await db.invoice.findFirst({
where: {
id: req.params.id,
ownerId: req.user.id,
tenantId: req.user.tenantId,
},
});Sequential IDs, UUIDs, slugs, and reference codes are all just identifiers. Changing the identifier should never be enough to gain access.
5. BOPLA — Broken Object Property Level Authorization
BOPLA is different. The caller may be allowed to access the object, but not all the fields inside it.
Vulnerable write example
routes/profile.tsapp.patch("/api/users/me", authMiddleware, async (req, res) => {
const updatedUser = await db.user.update({
where: { id: req.user.id },
data: req.body,
});
return res.json(updatedUser);
});A legitimate user may be allowed to update profile data, but not privileged fields such as role, status, tenantId, or isVerified.
examples/property-escalation.httpPATCH /api/users/me
Content-Type: application/json
{
"displayName": "Kaan",
"role": "admin",
"isVerified": true
}Secure write example
routes/profile.tsapp.patch("/api/users/me", authMiddleware, async (req, res) => {
const allowedFields = ["displayName", "avatarUrl", "timezone"];
const safeData = Object.fromEntries(
Object.entries(req.body).filter(([key]) => allowedFields.includes(key))
);
const updatedUser = await db.user.update({
where: { id: req.user.id },
data: safeData,
select: {
id: true,
email: true,
displayName: true,
avatarUrl: true,
timezone: true,
},
});
return res.json(updatedUser);
});Vulnerable read example
routes/profile.tsapp.get("/api/users/me", authMiddleware, async (req, res) => {
const user = await db.user.findUnique({
where: { id: req.user.id },
});
return res.json(user);
});Secure read example
routes/profile.tsconst user = await db.user.findUnique({
where: { id: req.user.id },
select: {
id: true,
email: true,
displayName: true,
createdAt: true,
},
});
return res.json(user);Common sensitive fields: password hashes, recovery codes, internal notes, fraud scores, admin flags, billing status, role mappings, approval state, and tenant ownership metadata.
6. BFLA — Broken Function Level Authorization
BFLA is about whether the caller can execute a function at all. This is not a record-level question and not a field-level question. It is an action-level question.
Vulnerable example
routes/admin.tsapp.post("/api/admin/users/:id/disable", authMiddleware, async (req, res) => {
await db.user.update({
where: { id: req.params.id },
data: { disabled: true },
});
return res.json({ success: true });
});This often happens because the frontend hides admin buttons and the backend assumes that hidden UI equals protected functionality.
Secure example
routes/admin.tsfunction requireRole(...roles: string[]) {
return (req, res, next) => {
if (!req.user || !roles.includes(req.user.role)) {
return res.status(403).json({ error: "Forbidden" });
}
next();
};
}
app.post(
"/api/admin/users/:id/disable",
authMiddleware,
requireRole("admin"),
async (req, res) => {
await db.user.update({
where: { id: req.params.id },
data: { disabled: true },
});
return res.json({ success: true });
}
);More explicit permission-based example
services/admin-policy.tsauthorize(req.user, "user:disable");
if (req.user.tenantId !== targetUser.tenantId) {
return res.status(403).json({ error: "Forbidden" });
}7. Side-by-Side Vulnerable vs Secure Patterns
| Layer | Vulnerable pattern | Secure pattern |
|---|---|---|
| BOLA | findUnique({ id }) | findFirst({ id, ownerId, tenantId }) |
| BOPLA | data: req.body | filter allowed fields + explicit select |
| BFLA | authMiddleware only | auth + role/permission policy |
8. How Secure Authorization Should Be Designed
A secure API should enforce authorization as a layered system, not a single middleware decision.
- Object-level: can the user access this record?
- Property-level: can the user read or modify these fields?
- Function-level: can the user perform this action?
authz/layered-model.tsauthorizeObject(req.user, invoice);
const safeInvoice = filterReadableFields(req.user, invoice);
authorizeAction(req.user, "invoice:export");Practical guardrails
- Never trust identifiers from the client.
- Never expose ORM models directly in responses.
- Never pass raw request bodies into update operations.
- Never assume hidden frontend actions are protected backend actions.
- Centralize authorization logic into policies or reusable guards.
- In multi-tenant systems, always validate tenant boundaries explicitly.
9. Final Takeaway
Authorization is not one check. It is a set of boundaries enforced at different levels of the API.
Secure APIs must answer three separate questions:
- Can this user access this specific object?
- Can this user see or modify these specific fields?
- Can this user execute this specific function?
If any one of those checks is missing, the API may still look secure from the outside — while remaining fundamentally exposed underneath.