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:
GEThas a rule, butDELETEon the same path was added later without one. In Spring,@RequestMappingwith nomethodaccepts 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:
/v1never got the rule/v2has (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
- 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.
- Name permissions, not job titles, in code:
payments:cancel, mapped to roles or scopes in one place (L27). - 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).
- Check gate 2 inside the operation, through one scoped loader (L34).
- 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(), notauthenticated(), 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.
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-stylenode(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 ifcustomerchecked 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".
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
- Check at the deputy. The first service does gate 2 on every ID it forwards. Simple, but
every caller of
documents-svcmust get it right forever. - 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.
- 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.