CyberNugget

L40 · BOLA and BFLA in depth

Module 6: API Security · ~20 min · Lesson 40 of 71

🎯 What you'll learn

  • The two authorization questions every API operation must answer, and the OWASP risk behind each
  • The BOLA patterns that survive a first fix: actions, second-hop IDs, owner changes, delegation
  • How BFLA shows up in REST APIs: guessable admin routes, method switching, roles from the request
  • A deny-by-default design in Spring Boot that makes both checks hard to forget

🔥 Why this matters

Say a team applied L34 and scoped every GET by ID in its payments-svc to the caller. Two months later a bug bounty report arrives. A customer token can call POST /v1/payments/PAY-7781/cancel on another customer's payment, and the same token can call GET /v1/ops/payments/search, built for the operations team. Neither is a GET by ID. The first is BOLA (API1:2023), the second is BFLA (API5:2023), and both shipped through code review.

1. Two questions, two gates

Every operation has to answer two separate questions:

 token: customer C-2001
   │ POST /v1/payments/PAY-7781/cancel
   ▼
 ┌─────────────────────────────────┐
 │ GATE 1: FUNCTION (API5, BFLA)   │ may this caller use this
 │ does C-2001 hold                │ operation at all?
 │ payments:cancel?                │ (token + route are enough)
 └────────────────┬────────────────┘
                  │ yes
                  ▼
 ┌─────────────────────────────────┐
 │ GATE 2: OBJECT (API1, BOLA)     │ may this caller use it on
 │ is PAY-7781 C-2001's,           │ THIS object?
 │ and still PENDING?              │ (needs the object's data)
 └────────────────┬────────────────┘
                  │ yes
                  ▼
              cancel it

Broken Function Level Authorization (BFLA) means a caller can invoke an operation their role or scopes shouldn't allow: the vertical direction from L34. Broken Object Level Authorization (BOLA) means the operation is fine but the object isn't the caller's: the horizontal direction. (L41 adds a third gate inside the object: which fields may you read or write.)

The gates are independent. "Customers may cancel payments" passes gate 1 and says nothing about PAY-7781. A lot of BOLA comes from teams treating a role check as the whole authorization check.

Gate 1 can be decided from the token and the route alone, so a framework rule or a gateway can enforce it. Gate 2 needs data only the owning service has.

2. BOLA patterns that survive a first fix

L34's fix was to scope the query to the caller. That fixes the endpoint you looked at. These are the places it tends not to reach:

Pattern Example Why it's missed Fix
Actions on an object POST /payments/{id}/cancel, /approve, /resend Reviews focus on reads Every action loads through the same scoped helper
Second-hop IDs A message you own that attaches a document you don't Only the first ID is checked Check every ID in the body, not just the path
Owner change on write PUT /payees/{id} with "customerId": "C-2002" The old owner is checked, the new one is saved Never take owner fields from the body (L41)
Delegated access Joint holders, advisors, power of attorney Ownership isn't one field One policy function, can(user, action, obj) (L27)
State-dependent rights Cancel only while PENDING Checked in the UI only Put state rules in the same policy

The second-hop case is the least obvious, so here it is in one request:

 POST /v1/messages                     token: sub = C-2001
 {
   "threadId":     "THR-4410",    ◀ checked: C-2001's thread  ✓
   "body":         "see attached",
   "attachmentId": "DOC-5512"     ◀ never checked: C-2002's
 }                                   tax statement           ✗

 then: GET /v1/messages/THR-4410  ◀ C-2001 reads their own
       → includes DOC-5512           thread... and the
                                     statement it now holds

Every individual check passed. The attacker used a legitimate write to copy someone else's object into a place they're allowed to read.

3. BFLA: how APIs expose the wrong functions

APIs make BFLA easy in three ways. REST naming is predictable (/v1/payments invites a guess at /v1/admin/payments). One service often mixes customer and staff operations. And the client decides which buttons to show, so hidden functions are only hidden from the UI. OWASP's API5 examples are exactly these: guessing an admin path, and swapping GET for DELETE.

The shapes to look for:

  • Guessable staff routes: /v1/ops/..., /admin/..., /internal/... published through the same Kong service and host as customer routes.
  • Method switching: GET has a rule, but DELETE on the same path was added later without one. In Spring, @RequestMapping with no method accepts every HTTP method.
  • Roles from the request: X-Role: ops, "role": "admin" in an invite body, or a JWT claim read without verifying the token (L25).
  • Old versions: /v1 never got the rule /v2 has (API9, L39).
  • "Only the job calls it": an endpoint for an Airflow DAG, reachable by any valid token because the route was published with a wildcard path.

The cure is to write down the rules before the code. A permission matrix lists every operation and who may call it, with an explicit default row:

 operation                        customer  ops   settle-job
 GET    /v1/payments/{id}           own     any       -
 POST   /v1/payments/{id}/cancel    own     any       -
 GET    /v1/ops/payments/search      ✗       ✓        -
 POST   /v1/payments:settle-batch    ✗       ✗        ✓
 DELETE /v1/payments/{id}            ✗       ✗        ✗
 (anything not listed)               ✗       ✗        ✗

"own" is gate 2; the ✓ and ✗ are gate 1. The last row is the important one.

4. The fix: deny by default, one permission per operation

  1. Deny by default at gate 1. Every route needs an explicit rule, and the final rule denies. A new endpoint is unreachable until someone decides who may call it.
  2. Name permissions, not job titles, in code: payments:cancel, mapped to roles or scopes in one place (L27).
  3. Separate staff APIs: their own route, host and token audience, ideally their own service and network path. A gateway such as Kong can then enforce coarse rules per route (L43).
  4. Check gate 2 inside the operation, through one scoped loader (L34).
  5. Test the matrix, with one token per column (L34's second deep dive).

🧪 Worked example: payments-svc, both bugs and the fix

Before. The security config says anyRequest().authenticated(), which means "any valid token may call anything":

@PostMapping("/v1/payments/{id}/cancel")
void cancel(@PathVariable String id) {
    payments.findById(id).orElseThrow()   // BOLA: anyone's
            .cancel();                     // payment
}

@GetMapping("/v1/ops/payments/search")     // BFLA: no rule
List<Payment> search(@RequestParam String status) {
    return payments.findByStatus(status);
}

What the bug bounty hunter sent, with a customer token for C-2001:

POST /v1/payments/PAY-7781/cancel          (PAY-7781 is C-2002's)
→ 204 No Content

GET /v1/ops/payments/search?status=PENDING
→ 200 OK [{"id":"PAY-7781","customerId":"C-2002", ...}, ...]

After, gate 1: one rule per operation, then deny. Spring's resource server maps the token's scope claim to SCOPE_... authorities by default (staff tokens might carry groups instead).

@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
  return http
    .authorizeHttpRequests(a -> a
      .requestMatchers(HttpMethod.GET, "/v1/payments/*")
        .hasAuthority("SCOPE_payments:read")
      .requestMatchers(HttpMethod.POST, "/v1/payments/*/cancel")
        .hasAuthority("SCOPE_payments:cancel")
      .requestMatchers(HttpMethod.GET, "/v1/ops/**")
        .hasAuthority("SCOPE_ops:payments:read")
      .anyRequest().denyAll())                // the default row
    .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
    .build();
}

After, gate 2: the scoped load, plus the state rule:

@PostMapping("/v1/payments/{id}/cancel")
void cancel(@PathVariable String id,
            @AuthenticationPrincipal Jwt jwt) {
    Payment p = payments
        .findByIdAndCustomerId(id, customerId(jwt)) // L34
        .orElseThrow(NotFoundException::new);
    if (p.status() != Status.PENDING)
        throw new ConflictException("not cancellable");
    p.cancel();
}

And a test that pins gate 1, using Spring Security's MockMvc support:

@Test
void customerCannotSearchOpsEndpoint() throws Exception {
    mvc.perform(get("/v1/ops/payments/search?status=PENDING")
            .with(jwt().authorities(
                new SimpleGrantedAuthority("SCOPE_payments:read"))))
       .andExpect(status().isForbidden());
}

Note the trade-off: with denyAll(), a developer who forgets a rule gets a 403 in their first test, not a silent hole in production. That's the failure you want.

🛠 Try it (optional, ~5 min)

List every route a Spring Boot service really exposes, then write its permission matrix. Run the service locally with the mappings endpoint turned on for this run only (never expose it in a deployed environment, L38):

./mvnw spring-boot:run -Dspring-boot.run.arguments=\
"--management.endpoints.web.exposure.include=mappings"

Then, in another terminal (needs jq):

curl -s localhost:8080/actuator/mappings | jq -r '
  .contexts[].mappings.dispatcherServlets.dispatcherServlet[]
  | .details.requestMappingConditions // empty
  | "\(if (.methods|length)==0 then "ANY"
        else (.methods|join(",")) end) \(.patterns|join(","))"'

Example output for a small service:

GET /v1/payments/{id}
POST /v1/payments/{id}/cancel
GET /v1/ops/payments/search
ANY /v1/payments/legacy-export

What to look for: any ANY line (a mapping with no method, so DELETE works too); any staff or job route in the same service as customer routes; and any route you can't fill a matrix row for. If your config uses anyRequest().authenticated(), every route without its own rule is open to every valid token. (This needs Spring Boot Actuator on the classpath. A 401 or 403 means your security config covers actuator too, which is good; IntelliJ's Endpoints tool window shows the same list. Gradle users: ./gradlew bootRun --args='--management...'.)

🧠 Key takeaways

  • Every operation needs two checks: may you call it (function, BFLA) and may you call it on this object (object, BOLA). Passing one says nothing about the other.
  • BOLA hides in actions, second-hop IDs in bodies, owner changes on write, and delegated access.
  • BFLA hides in guessable staff routes, unruled HTTP methods, roles taken from the request and old API versions.
  • Deny by default: anyRequest().denyAll(), not authenticated(), plus one permission per operation.
  • Write the permission matrix first, then test every cell.

📖 Words to know

Term Meaning
BFLA Broken Function Level Authorization (API5:2023): calling an operation your role or scopes shouldn't allow
BOLA Broken Object Level Authorization (API1:2023): acting on an object that isn't yours
Function-level check A rule about who may use an operation, decidable from the token and route
Object-level check A rule about who may act on a specific object, which needs that object's data
Second-hop ID An object ID inside a request body that points at a second object, often left unchecked
Method switching Sending a different HTTP method to a path whose other methods have no rule
Permission matrix A table of operations against callers, with the expected allow or deny in each cell
Deny by default Routes with no explicit rule are refused, so forgetting a rule fails safe

⏭ Next time

L41 moves to the third gate: mass assignment and excessive data exposure, where {"role": "admin"} sneaks into a PATCH and whole MongoDB documents leak in responses.

Deep dive 1

BOLA in GraphQL and batch endpoints

REST gives each object its own URL, so reviewers can see where IDs enter. GraphQL and batch endpoints put many objects behind one route. The gateway sees one request; the service resolves dozens of objects, each needing its own gate 2.

GraphQL: one endpoint, many doors

In GraphQL, every field that takes an ID is a door, and nested fields are doors behind doors:

 POST /graphql                         token: sub = C-2001
 query {
   a: payment(id: "PAY-7781") { amount payee { iban } }
   b: payment(id: "PAY-7782") { amount payee { iban } }
   c: payment(id: "PAY-7783") { amount payee { iban } }
   ... 97 more aliases ...
 }
   │
   ▼ ONE HTTP request, 100 object lookups
 Kong: 1 request against the rate limit
 resolver payment(id): runs 100 times; does it check owner?

Three things make GraphQL BOLA worse than REST:

  • Aliases batch guesses. The query above tries 100 IDs in one request, so per-request rate limits barely notice. Limit query cost and alias count, not just requests (L42).
  • Many entry points to one type. payment(id:), customer { payments }, and the Relay-style node(id:) field can all return a Payment. If only one resolver checks ownership, the others are open. node(id:) is the classic: it resolves any type by a global ID.
  • Nested resolvers trust the parent. customer(id: "C-2001") { payments } is safe only if customer checked C-2001; a nested resolver usually doesn't check again.

GraphQL's own documentation recommends putting authorization in the business logic layer, not in each resolver. The resolver asks one function, the same one the REST endpoints use.

Batch REST: one call, many IDs

Batch endpoints such as POST /v1/payments:batchGet {"ids": [...]} or a bulk export have the same problem. Decide up front:

  • Check every ID. Scope the whole query, not just the first ID.
  • Partial or all-or-nothing? Returning the caller's items and silently dropping others is usually best. Don't mark the others as "forbidden" vs "not found"; that tells an attacker which IDs exist.
  • Cap the list size (L42), and log the number of dropped IDs: a request where 99 of 100 IDs aren't yours is an enumeration signal.

Worked example: a scoped batch loader (Node + MongoDB)

GraphQL servers often use a DataLoader to batch lookups. Put the owner filter inside it, so every resolver that loads payments gets the check for free:

// VULNERABLE: loads whatever IDs any resolver asks for
const paymentLoader = new DataLoader(async (ids) => {
  const docs = await db.collection("payments")
    .find({ _id: { $in: ids } }).toArray();
  return ids.map((id) => docs.find((d) => d._id === id) ?? null);
});

// FIXED: one loader per request, scoped to the verified caller
function makePaymentLoader(caller) {
  return new DataLoader(async (ids) => {
    const docs = await db.collection("payments")
      .find({ _id: { $in: ids }, customerId: caller.customerId })
      .toArray();
    const byId = new Map(docs.map((d) => [d._id, d]));
    return ids.map((id) => byId.get(id) ?? null);   // not yours
  });                                               // = null
}
// in the GraphQL context factory:
// context: ({ req }) => ({ payments: makePaymentLoader(req.caller) })

Creating loaders per request matters for a second reason: a DataLoader caches results, and a cache shared across requests could serve one customer's payment to another.

🧠 Extra takeaways

  • GraphQL and batch endpoints put many objects behind one route; each object still needs gate 2.
  • Aliases turn one request into many guesses; limit query cost, not just request count.
  • Put the owner check in the shared loader or business layer, so every resolver inherits it.
  • Batch responses should drop items silently and never distinguish "not yours" from "doesn't exist".
Deep dive 2

The confused deputy: BOLA between services

BOLA isn't only about customers calling your API. Often the hole is between two of your own services. The pattern has a name: the confused deputy, from Norm Hardy's 1988 paper. A program with broad authority (the deputy) is tricked by a less-privileged caller into using that authority on the caller's behalf.

How it happens in a microservice estate

statements-svc checks the customer's token, then calls documents-svc to fetch a PDF. It uses its own client-credentials token (L23), which may read any document:

 C-2001 ──GET /v1/statements/STM-88213/pdf──▶ statements-svc
          (token: sub=C-2001)                   │
                                                │ checks STM-88213 is
                                                │ C-2001's ✓ ... or
                                                │ forgets to
                                                ▼
            GET /docs/DOC-5512   (token: client=statements-svc,
                                  scope=documents:read:any)
                                                ▼
                                          documents-svc
                                  "statements-svc may read any
                                   doc" → 200, whoever asked

documents-svc can't help: it has no idea which customer is behind the call. If statements-svc ever passes through an ID it didn't check (a second-hop ID, from section 2), the deputy hands over someone else's document. The same shape appears when an API queues an Airflow job with {"customerId": ...} from the request, and the job runs with a database user that can read every customer.

Three ways out

  1. Check at the deputy. The first service does gate 2 on every ID it forwards. Simple, but every caller of documents-svc must get it right forever.
  2. Forward the user's identity. Pass the user's token downstream so the second service can do its own gate 2. Forwarding the original token as-is is tempting but weak: its audience is the first API (L23), so the second service must accept tokens meant for someone else.
  3. Exchange the token. OAuth 2.0 Token Exchange (RFC 8693) lets the deputy trade the user's token for a new one aimed at the downstream service, still naming the user, and recording who is acting for them.

Worked example: a token exchange

statements-svc asks the authorization server for a token for documents-svc:

POST /oauth2/token HTTP/1.1
Host: auth.example.com
Authorization: Basic c3RhdGVtZW50cy1zdmM6Li4u   (deputy's creds)
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=eyJhbGciOiJSUzI1NiIs...      (C-2001's token)
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&audience=documents-svc
&scope=documents:read

The new token's claims:

{
  "iss": "https://auth.example.com",
  "sub": "C-2001",
  "aud": "documents-svc",
  "scope": "documents:read",
  "act": { "sub": "statements-svc" },
  "exp": 1794049200
}

Now documents-svc does its own scoped query on sub, and its audit log can say "statements-svc, acting for C-2001, read DOC-5512". The act (actor) claim is defined by RFC 8693. Support varies by identity provider (some offer it only in recent versions or behind a feature flag), so check yours before designing around it. Where it isn't available, a signed internal header that carries the user's ID, set only by the first service and verified by the second, is a common middle ground.

For Airflow, the same idea: record the requesting customer and operator in the job's parameters, and have the task query with that customer's scope, using a database user limited to what the job needs (L53), not a global reader.

🧠 Extra takeaways

  • A service with broad credentials that forwards unchecked IDs is a confused deputy.
  • The downstream service can only do gate 2 if it knows who the end user is.
  • Token exchange (RFC 8693) gives a downstream-specific token that names the user (sub) and the deputy (act).
  • Background jobs are deputies too; carry the user's identity into them and limit their database rights.

The quiz for this lesson and the Tests section need JavaScript.