AuditCoreAuditCore
criticalCWE-639OWASP API #1IDORBroken Object Level Authorization

How to fix BOLA / IDOR

BOLA (Broken Object Level Authorization) is the #1 most-exploited API vulnerability in 2026 per OWASP. The fix is conceptually simple — verify ownership on every object access — but easy to get wrong in distributed systems. Here's the complete playbook with code in 4 languages, plus how to verify the fix sticks under regression.

What is BOLA?

BOLA — also known as IDOR (Insecure Direct Object Reference) — happens when an API endpoint accepts an object ID in the URL or body and returns the requested object WITHOUT verifying that the authenticated user is allowed to access it. The classic example: GET /api/orders/12345. The server checks that the request has a valid session, looks up order 12345, and returns it. What it forgets to check: does the session user own order 12345?

If 12345 belongs to a different customer, the attacker just exfiltrated their data with a single number change. Multiply by every numeric or sequential ID exposed in your API and you have the most common category of API breach in modern SaaS — Optus, T-Mobile, Verizon and dozens of others have shipped this exact bug to production.

BOLA is OWASP's #1 because it satisfies a perfect storm of properties: easy to find (just increment a number), high impact (full data access), invisible to most scanners (looks like a legitimate authenticated request), and structurally hard to fix (requires checks on every endpoint, every time). Generic security scanners miss it because they don't have the role context to test cross-account access.

The harder cousin, BFLA (Broken Function Level Authorization), is similar but at the function level: a free-tier user calling POST /api/admin/invite. Same root cause — missing or insufficient authorization check — but at the function-routing level instead of object-ownership level. Most BOLA fixes also fix BFLA if applied at the right layer.

What an attacker can do

The concrete impact of leaving BOLA unpatched.

Full account takeover via mass enumeration

Iterate through user IDs, dump every user's profile, password reset tokens (if exposed), email addresses, billing info. One scan = the entire user database.

Cross-tenant data leakage in multi-tenant SaaS

Tenant A's free user reads tenant B's enterprise data. Catastrophic for B2B SaaS — single finding = entire customer base contractually breached.

PII compliance violations (GDPR, CCPA, HIPAA)

BOLA exposing PII triggers mandatory breach notification under GDPR (72h) and CCPA. Healthcare BOLA = HIPAA violation, $50k-$1.5M per incident.

Privilege escalation via object ownership chain

Read another user's API tokens or password reset link via BOLA, then become them. BOLA → ATO is a documented 1-step chain.

Bypass of all 'paid feature' gates

Free users access endpoints the UI doesn't expose to them. Read paid customer billing data, export their subscriber lists, query premium features.

Brand damage that survives the technical fix

BOLA breaches reach press because the technical detail is easy for journalists to explain. Reputational impact lasts 12-24 months even after the fix.

How do I know if I'm vulnerable?

Manual: pick any endpoint that returns 'your own data' — GET /api/orders/123, GET /api/users/me/profile, GET /api/messages/456. Note the object ID. Now create a second account, find that account's equivalent ID (its order, its profile). Send Account A's session cookie + Account B's object ID. If you get back Account B's data, you have BOLA.

Automated: this needs multi-role authenticated scanning, which most generic scanners can't do. AuditCore's Auth Replay scanner crawls as multiple authenticated roles, captures every API request, then replays each role's traffic as the others. If a free user's request to /api/v1/orgs/123 returns the paid user's data, that's a confirmed BOLA finding with reproduction evidence.

Code review pattern: grep your codebase for routes that take an :id, :uuid or similar URL parameter. For each, find where the database query happens. Look for an explicit ownership check between the authenticated user and the queried object. Any route missing that check is probably BOLA. The check has to happen IN THE QUERY (WHERE user_id = $current_user) — checking AFTER the query lets a slow attacker race the response.

How to fix BOLA

6 ordered steps. Apply them in order — each builds on the previous.

  1. 1

    Identify every endpoint that accepts an object identifier

    Enumerate every route that takes an :id, :uuid, :slug or similar URL parameter — this is your BOLA attack surface.

    Use your routing config (Express app.get, FastAPI @router.get, Rails routes.rb, Spring @RequestMapping) as the source of truth. List every route with a path parameter. For each, identify what database object it loads. This is your candidate list — every one of these needs an ownership check.

  2. 2

    Add authorization check INSIDE the database query

    Filter by user_id (or org_id, tenant_id) directly in the WHERE clause, not as a post-query check.

    The naive fix is to load the object then check ownership. That's vulnerable to TOCTOU bugs and unnecessarily expensive. The correct pattern: include the user/org/tenant ID in the WHERE clause so the query returns null for objects the user doesn't own. This makes BOLA a 404 instead of a vulnerability.

    TypeScript / PrismaStep 2
    // VULNERABLE — naive lookup
    app.get('/api/orders/:id', async (req, res) => {
      const order = await prisma.order.findUnique({
        where: { id: req.params.id },
      });
      res.json(order); // returns ANY user's order
    });
    
    // SECURE — ownership in the query
    app.get('/api/orders/:id', requireAuth, async (req, res) => {
      const order = await prisma.order.findFirst({
        where: {
          id: req.params.id,
          userId: req.user.id, // <-- ownership check
        },
      });
      if (!order) return res.status(404).end();
      res.json(order);
    });
  3. 3

    Use random / unguessable object IDs (UUID, ULID)

    Sequential integer IDs make BOLA enumeration trivial. UUIDs and ULIDs add defense in depth.

    This is NOT a substitute for ownership checks — UUIDs leak through email addresses, referer headers, server logs and shared links all the time. But sequential IDs let an attacker dump 'IDs 1 to 100,000' in a script. UUIDs reduce that to 'guess a 122-bit number', which is computationally infeasible. The combination of UUIDs + ownership checks makes BOLA exploitation an order of magnitude harder.

    PostgreSQLStep 3
    -- Migrate sequential IDs to UUID v7 (time-ordered, sortable)
    ALTER TABLE orders ADD COLUMN public_id UUID DEFAULT gen_random_uuid();
    CREATE UNIQUE INDEX orders_public_id_idx ON orders(public_id);
    
    -- Use public_id in URLs, keep internal id for FK joins
    SELECT * FROM orders WHERE public_id = $1 AND user_id = $2;
  4. 4

    Add a centralized authorization layer (policy / guard)

    Don't write ownership logic per route — extract it into a policy class or middleware that every route uses.

    Per-route auth checks rot. A new endpoint gets added, the developer forgets the check, and you have a regression. The fix is centralization: a policy object (e.g. Pundit in Rails, CASL in JS, oso in Python/Go) that every endpoint calls before returning data. Policies are easier to audit and test than scattered if-statements.

    Python / FastAPI + casbinStep 4
    from casbin import SyncedEnforcer
    
    enforcer = SyncedEnforcer("policy.conf", "policy.csv")
    
    def authorize(user_id: str, obj: str, act: str):
        if not enforcer.enforce(user_id, obj, act):
            raise HTTPException(status_code=403)
    
    @router.get("/orders/{order_id}")
    async def get_order(order_id: str, user: User = Depends(current_user)):
        authorize(user.id, f"order:{order_id}", "read")
        return await db.order.find_one({"_id": order_id})
  5. 5

    Cover BFLA at the same time — function-level authorization

    Same fix logic, applied at the route/function level. POST /api/admin/* should reject non-admins at the middleware layer.

    BFLA is BOLA's cousin: the request hits a function the user isn't authorized to call (vs. an object). Group your routes by required role at the router level — admin routes go through an adminMiddleware that rejects non-admins before any handler runs. This catches BFLA before the handler can leak data.

    ExpressStep 5
    // Middleware factory
    function requireRole(role: 'admin' | 'manager' | 'user') {
      return (req, res, next) => {
        if (!req.user || !req.user.roles.includes(role)) {
          return res.status(403).json({ error: 'Forbidden' });
        }
        next();
      };
    }
    
    // Group admin routes — every one inherits the check
    const adminRouter = express.Router();
    adminRouter.use(requireRole('admin'));
    adminRouter.post('/users/invite', inviteHandler);
    adminRouter.delete('/users/:id', deleteUserHandler);
    
    app.use('/api/admin', adminRouter);
  6. 6

    Add unit + integration tests for cross-account access

    Every endpoint that loads an object needs at least one test asserting User A cannot access User B's object.

    Tests are how you prevent regression. Pattern: in your test setup, create two users (A and B) and an object owned by A. Then attempt to access A's object as B. Assert 404 or 403 — never the object itself. Run these on every PR. CI catches the day a developer 'temporarily disables' the ownership check for debugging and forgets to put it back.

    TypeScript / JestStep 6
    describe('GET /api/orders/:id — BOLA prevention', () => {
      let userA, userB, orderA;
    
      beforeEach(async () => {
        userA = await createUser();
        userB = await createUser();
        orderA = await createOrder({ userId: userA.id });
      });
    
      it('returns the order to its owner', async () => {
        const res = await api.get(`/orders/${orderA.id}`)
          .set('Cookie', sessionFor(userA));
        expect(res.status).toBe(200);
        expect(res.body.id).toBe(orderA.id);
      });
    
      it('returns 404 to a different user (NOT 403 or 200)', async () => {
        const res = await api.get(`/orders/${orderA.id}`)
          .set('Cookie', sessionFor(userB));
        expect(res.status).toBe(404);
        expect(res.body).not.toHaveProperty('id');
      });
    });

How to verify the fix

Manually: open two browser sessions, log into Account A in one and Account B in the other. Capture an object ID from Account A (any ID — order, message, file). Now in Account B's session try to access that ID directly. You should get 404 or 403, never the object's data. Repeat for every object type your API exposes.

Automatically: AuditCore's Auth Replay scanner is built for exactly this — multi-role crawl + cross-replay. Run a Business-tier scan with two test accounts, and the BOLA findings come back with reproduction evidence (the actual data leaked, the role that leaked it, the role that received it). Free tier on the homepage gives you a sense of whether basic IDOR exists; the multi-role replay needs the Business tier.

Long-term: add the unit tests from step 6 to your CI. They catch BOLA regressions on every PR. Combine with weekly scheduled AuditCore scans on staging — the diff alerts catch the cases where someone deploys a new endpoint without proper authorization checks.

FAQ

Frequently asked questions

Is BOLA the same as IDOR?+

Functionally yes, terminology differs. IDOR (Insecure Direct Object Reference) is the older term coined for the same vulnerability class. BOLA is OWASP's modern API-specific naming. Use them interchangeably — same root cause (missing object-level authorization check), same fix (verify ownership in the query).

What's the difference between BOLA and BFLA?+

BOLA (Broken Object Level Authorization) is about object access: 'can user X read object Y?'. BFLA (Broken Function Level Authorization) is about function access: 'can user X call function Y?'. BOLA = wrong user reading another's data. BFLA = wrong user role calling admin functions. Both have the same root cause (missing authz check) but at different layers. Step 5 above covers BFLA.

Are JWTs / OAuth scopes enough to prevent BOLA?+

No. JWTs prove identity (you are user X). OAuth scopes prove permission to call a function class. Neither answers 'does user X own this specific object?'. That's the BOLA check, and it has to happen at the data layer. JWTs help with BFLA (function-level authz) but BOLA needs object-level checks.

Will GraphQL save me from BOLA?+

No — GraphQL is BOLA-prone in different ways. Resolvers run independently, and a query that traverses entities (user → orders → items) needs ownership checks at each level. Many GraphQL implementations check at the root resolver but not at nested ones. Use a permission framework like graphql-shield or apply the same database-level filter pattern at every resolver.

Should I use UUIDs to prevent BOLA?+

UUIDs alone are NOT a fix — they're defense in depth. Object IDs leak through emails, referer headers, shared links, server logs. UUIDs make brute-force enumeration computationally infeasible (122-bit space) but don't stop targeted attacks where the attacker already knows an ID. Always combine with ownership checks. UUIDs make exploitation harder; ownership checks make it impossible.

What about row-level security in Postgres or MySQL?+

Postgres RLS (CREATE POLICY) is excellent for BOLA defense. Set the current tenant ID as a Postgres session variable and add a policy that filters every row by that variable. Every query — including ones a developer forgot to scope — gets filtered automatically. This is the strongest defense available, but requires connection-pooling discipline (each connection's session must be properly scoped per request).

How do I test for BOLA in CI?+

Two ways. Unit/integration tests like step 6 (every endpoint has a 'wrong user gets 404' test). Plus add the AuditCore GitHub Action to scan PRs — set fail-on: critical to block merges that introduce new BOLA findings. The combination catches both regressions in existing endpoints and new endpoints lacking ownership checks.

Why is BOLA still rampant in 2026?+

Three reasons. (1) It looks like a legitimate authenticated request — generic scanners can't tell A's data from B's data. (2) Modern apps have hundreds of endpoints; missing one check is statistically inevitable without centralization. (3) Refactors silently break it — moving from monolith to microservices often loses ownership context that was implicit in the monolith's session-aware DB layer.

Don't just guess — scan and verify

AuditCore Free Trial scans your homepage for BOLA and 50+ other vulnerability classes. No credit card. Results in 60 seconds.