Back to Guides

    API Authorization 101: BOLA vs BOPLA vs BFLA

    Authentication proves identity. Authorization defines boundaries. In API security, the most damaging failures usually happen after login — when the system does not correctly enforce access to objects, properties, or functions.

    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.

    CategoryCore QuestionOWASP RiskTypical Failure
    BOLACan this user access this specific record?API1:2023Changing an ID exposes another user's object
    BOPLACan this user read or modify these fields?API3:2023Sensitive fields are exposed or writable
    BFLACan this user perform this action?API5:2023Low-privilege users invoke admin functions

    3. The Difference, Clearly

    These categories are related, but they break at different layers of an authorization model.

    TypeWhat is being protected?Attacker changesExampleImpact
    BOLAA record or objectIdentifier/api/invoices/9002/api/invoices/9003Unauthorized data access
    BOPLAFields inside an objectPayload or response surface{ role: "admin" }Privilege escalation or data leakage
    BFLAAn operation or capabilityRoute, method, or privileged functionPOST /api/admin/delete-userAdministrative 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.ts
    app.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);
    });
    The API verifies authentication, but not whether the invoice belongs to the caller.

    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.ts
    app.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);
    });
    Lookup and authorization are combined so the record is only returned if it belongs to the authenticated user.

    Multi-tenant secure example

    services/invoice-service.ts
    const invoice = await db.invoice.findFirst({
      where: {
        id: req.params.id,
        ownerId: req.user.id,
        tenantId: req.user.tenantId,
      },
    });
    In SaaS systems, ownership and tenant isolation often both matter.

    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.ts
    app.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);
    });
    Blindly passing req.body into the persistence layer creates a mass assignment style property authorization problem.

    A legitimate user may be allowed to update profile data, but not privileged fields such as role, status, tenantId, or isVerified.

    examples/property-escalation.http
    PATCH /api/users/me
    Content-Type: application/json
    
    {
      "displayName": "Kaan",
      "role": "admin",
      "isVerified": true
    }
    If these fields are writable through a general user endpoint, the API is vulnerable.

    Secure write example

    routes/profile.ts
    app.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);
    });
    Only explicitly allowed fields are accepted, and only explicitly selected fields are returned.

    Vulnerable read example

    routes/profile.ts
    app.get("/api/users/me", authMiddleware, async (req, res) => {
      const user = await db.user.findUnique({
        where: { id: req.user.id },
      });
    
      return res.json(user);
    });
    Returning the raw model may expose fields that should never leave the server.

    Secure read example

    routes/profile.ts
    const user = await db.user.findUnique({
      where: { id: req.user.id },
      select: {
        id: true,
        email: true,
        displayName: true,
        createdAt: true,
      },
    });
    
    return res.json(user);
    Use explicit response shaping. Never expose internal models directly.

    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.ts
    app.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 });
    });
    Any authenticated user can call an administrative function if no role or permission check exists.

    This often happens because the frontend hides admin buttons and the backend assumes that hidden UI equals protected functionality.

    Secure example

    routes/admin.ts
    function 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 });
      }
    );
    Administrative endpoints require explicit function-level authorization.

    More explicit permission-based example

    services/admin-policy.ts
    authorize(req.user, "user:disable");
    
    if (req.user.tenantId !== targetUser.tenantId) {
      return res.status(403).json({ error: "Forbidden" });
    }
    Function-level controls are often strongest when paired with tenant or scope checks.

    7. Side-by-Side Vulnerable vs Secure Patterns

    LayerVulnerable patternSecure pattern
    BOLAfindUnique({ id })findFirst({ id, ownerId, tenantId })
    BOPLAdata: req.bodyfilter allowed fields + explicit select
    BFLAauthMiddleware onlyauth + 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.ts
    authorizeObject(req.user, invoice);
    const safeInvoice = filterReadableFields(req.user, invoice);
    authorizeAction(req.user, "invoice:export");
    Authorization becomes more reliable when object, property, and function checks are treated as separate responsibilities.

    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.