Resource-based authorization and BOLA
“How do you make sure a user can only read or change their own records?”
Checking the user against the record they asked for, after loading it. Skipping this check is the most common API hole: BOLA (Broken Object Level Authorization).
See it
GET loans/7
1/7Dealer B, signed in with a valid token, changes the id in the address: loan 7, which is dealer A's.
In plain words
[Authorize] and roles answer "who are you, and what kind of user are you?". They run before your action, when the record has not been loaded yet, so they can't know which record the user asked for. Say a dealer user may read loan applications. Dealer B asks for GET /api/applications/7, and application 7 belongs to dealer A. The role check passes: B is a dealer. If nothing else checks, B reads A's customer. This hole is called BOLA, Broken Object Level Authorization: the API checks the user, but not the object (the record). It is number one on the OWASP API Security Top 10, a well-known list of the most common API holes. The fix is resource-based authorization: load the record first, then ask .NET's "may this user use this record?". Three pieces do the work. A policy is a named rule (here "SameDealer"); the controller asks for the rule by its name. A requirement is an empty class that stands for the rule. A handler is the class that runs the check: it compares a claim on the user (dealer_id) with a field on the record (). If they differ, the check fails and the controller returns 404 Not Found, not 403, so the API does not even confirm the record exists.
- Attribute and policy checks run before the action, so they can't see the record. For per-record rules I load the record, then call .
- A requirement is a marker class, and an is the class that checks it: it compares a claim, like the dealer id, with the record's owner and calls only on a match. No means the check fails.
- Missing that check is BOLA, Broken Object Level Authorization, the top item in the OWASP API Security Top 10: valid users reading other dealers' data by changing an id. I return 404 for someone else's record so ids can't be probed.
- For lists, I filter the query by the caller's dealer id instead of using the handler, and every by-id endpoint gets a test that another dealer's id returns 404.
In the lending API
The controller, the handler, then the set-up. Program.cs runs once at start-up, so the walk goes back to the check step there. Tap Next line to walk dealer B's request.
Lending.sln · where these 3 files sit
- src/Api/
- Controllers/
- Auth/
- Controllers/
[Authorize(Roles = "Dealer")] passes: B really is a dealer. It can't know whose loan 7 is.What they ask next
Interviewers check that you can explain every line. Tap a question to open it.
Why not check it in a filter or with an attribute?
Both run before the action loads the record, so they don't know whose it is. A filter (: code MVC runs around an action) could load it first, but then the record is loaded twice. Loading it once in the action and calling keeps the check next to the data.
What about list endpoints?
Filter the query itself: only rows whose dealer id equals the caller's claim. Then other dealers' rows never leave the database. With EF Core, a global query filter (a condition added to every query on a table) can add that for you.
Does a GUID (a long random id) fix BOLA?
No. It makes ids hard to guess, but ids leak through logs, links and screenshots. The check is still needed; a random id is only an extra layer.
Traps
- Don't say
The user is authenticated and has the right role, so the request is safe.SayRole says what kind of user; only a per-record check says whether this record is theirs. - Don't say
Return 403 Forbidden for someone else's record.SayReturn 404. A 403 tells the caller the record exists.