SecLab

Access control & IDOR

V8A01API1CWE-639
01

What it is

Access control is the part that answers "what may THIS user do to THIS object". A broken access control bug is when the system already knows who you are (authentication succeeded) but never checks whether you are allowed. IDOR/BOLA is the common form: change id=42 to id=43 and read someone else's data.

02

Why you should care

Relevance: CoreExpected: L2

A01 has topped the OWASP Top 10 two editions running, and not because it is hard to understand — because it is the bug with no syntactic signature. SQL injection has a quote you can grep for; a missing authorisation check looks exactly like a present one: both are a single FindByIdAsync(id). No linter finds it, no WAF blocks it, and automated scanners are blind to it because catching it requires understanding who ought to read what — that is, your business rules.

From a developer's side, three properties make it recur forever:

  • It lives at every endpoint. A new endpoint is a new chance to forget.
  • Happy-path tests stay green. Alice reads Alice's order — pass. Nobody writes "Alice reads Bob's order" unless a convention forces them to.
  • It decays silently. The endpoint checks ownership today; next month somebody adds an overload "for the internal job" that drops the userId argument.

On impact: no escalation needed, no payload needed. A for loop over id is the entire database, and in your logs it looks like ordinary traffic from a signed-in user.

03

How the attack works

The mechanism is an omitted question. The system asks "who are you?", then goes straight to the query without asking "is this yours?".

Diagram source
sequenceDiagram    autonumber    actor B as Bob (signed in)    participant API    participant DB    B->>API: GET /api/orders/1042<br/>Authorization: Bearer <Bob's token>    Note over API: ✅ Token valid → Bob is Bob.<br/>❌ Nobody asks whether 1042 is Bob's.    API->>DB: SELECT * FROM orders WHERE id = 1042    DB-->>API: Alice's order    API-->>B: 200 — Alice's address, card last-4, purchase history

The technically important point: the query is where the bug lives, not the middleware. WHERE id = 1042 leaves no seam for middleware to enter. The middleware knows Bob is Bob; it does not know what 1042 is. That is why the correct fix belongs in the query — see block 6.

Four forms, needing four different fixes:

FormExampleFix
BOLA / IDOR (API1)GET /orders/1042 belonging to someone elseOwnership in the WHERE
BFLA (API5)DELETE /admin/users/7 from a normal accountPer-function checks, deny by default
Mass assignment (API3)PATCH {"role":"admin"}A separate input DTO, never bind onto the entity
Cross-tenant escalation?tenantId= of another tenantTenant comes from the token, never the request

The second form deserves a note: it usually shows up as an undocumented endpoint. The UI hides the Delete button from normal users, so nobody tests the DELETE route — but the route is still there, and Swagger tells anyone who asks.

Diagram description: Sequence diagram: signed-in Bob sends GET /api/orders/1042 with his own valid token. The API confirms the token is valid so it knows Bob is Bob, but never checks whether order 1042 belongs to Bob, and queries the database by id alone. The database returns Alice's order and the API hands it to Bob, including her address, card last-four digits and purchase history.

04

Concrete example

One endpoint, three requests. The second is the bug; the third is why status codes deserve attention.

HTTP
# 1 — Bob reads his own order. As designed.GET /api/orders/1041 HTTP/1.1Authorization: Bearer eyJ…bob HTTP/1.1 200 OK{"id":1041,"total":"429000","address":"12 Nguyen Hue, D1"}
HTTP
# 2 — Bob reads Alice's order. Same token, same endpoint, one digit different.GET /api/orders/1042 HTTP/1.1Authorization: Bearer eyJ…bob HTTP/1.1 200 OK                       ← this is the bug{"id":1042,"total":"1250000","address":"88 Le Loi, D3","cardLast4":"4242"}
HTTP
# 3 — After the fix. 404, NOT 403.GET /api/orders/1042 HTTP/1.1Authorization: Bearer eyJ…bob HTTP/1.1 404 Not Found{"errorCode":"ER_ORDER_NOT_FOUND"}

Why 404 and not 403: a 403 confirms order 1042 exists. Given a loop over id, the difference between 403 and 404 is a complete map of which orders are real — enough to infer company size, growth rate, and who the large customers are.

C#Line 11 queries with no scope. Line 14 is the RIGHT check in the wrong place — and it will get deleted.
[HttpGet("/api/orders/{id:long}")]public async Task<IActionResult> Get(long id, CancellationToken ct){    // [Authorize] toàn cục đã chạy: chúng ta BIẾT đây là ai.    var me = _currentUser.UserId;     // ❌ Truy vấn theo id, không theo (id, chủ sở hữu). Middleware nào cũng không    //    chen được vào đây, vì nó không biết 1042 là của ai cho tới khi đã đọc.    var order = await _db.Orders        .Include(o => o.Items)        .FirstOrDefaultAsync(o => o.Id == id, ct);     // ❌ Câu kiểm này ĐÚNG về hành vi và SAI về vị trí. Xoá dòng này ra thì mọi    //    test happy-path vẫn xanh — nên nó là dòng bị xoá trong lần refactor sau,    //    hoặc bị bỏ qua trong overload thứ hai mà ai đó thêm cho job nội bộ.    if (order is null || order.UserId != me) return NotFound();     return Ok(OrderMapper.ToDetail(order));}
05

What happened in the wild

First American Financial, May 2019 — 885 million documents. Links to real-estate transaction documents took the form …/DocumentHandler?id=<sequential number> with no authorisation check at all. Anyone holding one valid link could read every other by subtracting 1. The records included bank account numbers, photographed tax IDs, and driving licences. This is IDOR at its purest — no technique, just a loop.

Optus, September 2022 — ~9.8 million Australian customers. An unauthenticated API on a subdomain, with a sequential contactId. The ACMA proceedings state the endpoint was left behind after an infrastructure change. Exactly the lesson from block 2: the endpoint you forget is the endpoint nobody authorises.

Peloton, 2021. The API returned profile data (age, weight, city, workout history) for any userId, including profiles explicitly set to private — among them a sitting US president's. Notable because the first fix merely required being logged in, and any free account could still read everything.

06

How to defend

Layer 1

Ownership in the query, not in an `if`

mandatory

This is the single most important control on the page, and the reason is that it cannot be forgotten: a SELECT … WHERE id = @id AND user_id = @me missing the second condition returns wrong data in the very first test. By contrast, an if (order.UserId != me) throw; placed AFTER the query still passes the happy path when deleted — and that is the kind of line a refactor deletes.

Concretely: the repository must have no GetById(id) returning a user-scoped entity. Only GetById(id, userId). The method signature is where this rule lives, so the compiler enforces it instead of code review.

C# · Layer 1Ownership lives in the signature and in the WHERE. There is no path that forgets it.
// ── Repository ───────────────────────────────────────────────────────────────public interface IOrderRepository{    /// <summary>    /// KHÔNG có overload GetByIdAsync(long) nào. Đây là điểm quan trọng nhất của    /// cả bản vá, và nó là một quyết định về KÝ HIỆU HÀM, không về nội dung hàm:    /// khi phương án "truy vấn không có chủ sở hữu" không tồn tại trong API của    /// repository thì không ai gọi nó được, kể cả người viết endpoint tháng sau    /// không đọc trang này.    ///    /// Trình biên dịch làm việc của code review, và nó không bao giờ mệt.    /// </summary>    Task<Order?> GetByIdAsync(long id, UserId owner, CancellationToken ct);} public sealed class OrderRepository : IOrderRepository{    private readonly SecLabDbContext _db;     public OrderRepository(SecLabDbContext db) => _db = db;     public Task<Order?> GetByIdAsync(long id, UserId owner, CancellationToken ct) =>        _db.Orders           .Include(o => o.Items)           // Ownership Ở TRONG câu truy vấn. Bỏ điều kiện thứ hai ra thì test           // cross-tenant đỏ NGAY, không phải đỏ ở một hệ quả xa xôi nào.           .FirstOrDefaultAsync(o => o.Id == id && o.UserId == owner.Value, ct);} // ── Endpoint ─────────────────────────────────────────────────────────────────[HttpGet("/api/orders/{id:long}")]public async Task<IActionResult> Get(long id, CancellationToken ct){    var order = await _orders.GetByIdAsync(id, _currentUser.UserId, ct);     // 404, KHÔNG 403. 403 xác nhận đơn 1042 tồn tại, và với một vòng for thì    // sự khác nhau giữa 403 và 404 chính là một bản đồ đầy đủ về đơn hàng nào có    // thật — đủ để suy ra quy mô doanh nghiệp và ai là khách hàng lớn.    if (order is null)        throw new NotFoundException(CatalogErrorsList.ORDER_NOT_FOUND);     return Ok(OrderMapper.ToDetail(order));} // ── Mặc định TỪ CHỐI, đặt một lần ở Program.cs ───────────────────────────────// Chiều này quan trọng: [Authorize] là mặc định, [AllowAnonymous] là ngoại lệ// phải gõ ra. Chiều ngược lại nghĩa là controller mới thêm vào sẽ mở toang, và// không ai biết cho tới khi có người tìm thấy nó.builder.Services.AddAuthorizationBuilder()    .SetFallbackPolicy(new AuthorizationPolicyBuilder()        .RequireAuthenticatedUser()        .Build());
Layer 1b

One authorisation decision point

For rules richer than ownership (sharing, org roles, delegation), do not scatter ifs across handlers. One IAuthorizationService.Authorize(user, action, resource) — one place to read, one place to test, one place to audit. In ASP.NET Core: policy-based authorization with IAuthorizationHandler.

Layer 1c

Deny by default

In ASP.NET Core: [Authorize] as a global filter, with [AllowAnonymous] the exception you have to write. The reverse (open by default, [Authorize] as the exception) means a newly added controller ships with no authorisation at all — and nobody notices until somebody finds it.

Layer 2

Row-Level Security in the database

This layer catches exactly what layer 1 will miss: a new query written by someone else. Postgres RLS driven by current_setting('app.user_id') means even an incorrectly written query returns no rows belonging to others. It does not replace layer 1 (poor error messages, harder debugging) — it is the safety net.

YAML · Layer 2Layer 2: RLS catches exactly what layer 1 misses — a new query written by somebody else.
# Postgres RLS. Đây là lưới an toàn, KHÔNG phải bản vá chính: thông báo lỗi của nó# kém (0 hàng, không nói vì sao) và nó làm việc debug khó hơn. Giá trị của nó nằm ở# chỗ khác — nó bảo vệ câu truy vấn mà bạn CHƯA viết.migration: |  ALTER TABLE core."order" ENABLE ROW LEVEL SECURITY;   -- FORCE để chính chủ bảng cũng phải chịu policy. Không có FORCE thì role sở hữu  -- bảng đi qua policy như không có gì, và trên nhiều hệ thống đó chính là role  -- mà app đang dùng — nên thiếu dòng này là RLS bật mà không chặn ai.  ALTER TABLE core."order" FORCE ROW LEVEL SECURITY;   CREATE POLICY order_owner_only ON core."order"    USING      (user_id = current_setting('app.user_id', true)::uuid)    WITH CHECK (user_id = current_setting('app.user_id', true)::uuid); connection_setup: |  -- Đặt trên MỖI connection lấy từ pool, trong một interceptor của EF Core.  -- SET LOCAL để giá trị chết theo transaction: nếu nó sống qua khỏi transaction  -- thì connection tiếp theo lấy từ pool thừa hưởng danh tính của request trước —  -- một lỗi phân quyền tệ hơn cả lỗi mà RLS đang định vá.  SET LOCAL app.user_id = $1; verify: |  SET app.user_id = '00000000-0000-0000-0000-000000000001';  SELECT count(*) FROM core."order" WHERE id = 1042;   -- phải là 0  SELECT relrowsecurity, relforcerowsecurity    FROM pg_class WHERE relname = 'order';             -- phải là t, t
Layer 3

Unguessable IDs, plus logging

UUIDv7 instead of sequential integers: not a control in itself (IDs leak everywhere) but it removes bulk enumeration, turning "one for loop" into "you must already know each id". Add an alert when one account collects many 404s on the same route — that is the signature of a loop in progress.

07

Verifying the fix

There is really only one check that matters on this topic, and it is the one almost no codebase has: every endpoint taking an id must have a cross-tenant test.

1. The cross-tenant test, as an enforced convention. See the csharp / test tab. Shape: two users in the fixture, and the test asserts user A cannot read user B's, with 404 as the correct status. The crucial part is making it a countable convention — see check 3.

2. Check repository signatures, merge-blocking:

Shell
# A repository for a user-scoped entity must not expose a one-argument GetById.grep -rnE 'Task<[A-Za-z]+\??> GetByIdAsync\(([A-Za-z]+Id )?id' \  --include='*Repository.cs' src/ | grep -vE 'userId|tenantId|ownerId' \  && { echo "GetByIdAsync without user scope — blocked"; exit 1; }exit 0

3. Measure authz coverage, not line coverage. Enumerate every route containing {id} from Swagger or EndpointDataSource, cross-reference against tests named *_returns_404_for_other_user. Any route with no matching test fails the build. This is the check that turns "remember to write the test" into "you cannot merge without it":

Shell
dotnet run --project tools/AuthzCoverage -- --fail-on-missing

4. Check deny-by-default is intact:

Shell
# Every controller sits under the global [Authorize]; each [AllowAnonymous] is listed and reviewed.grep -rn "AllowAnonymous" --include='*.cs' src/ | diff - docs/allowed-anonymous.txt \  || { echo "A new anonymous endpoint appeared without review"; exit 1; }

5. Check RLS actually blocks (layer 2 is easy to believe is on when it is not):

SQL
SET app.user_id = '00000000-0000-0000-0000-000000000001';-- Must return 0 even though order 1042 exists:SELECT count(*) FROM core."order" WHERE id = 1042;-- And confirm nobody disabled the policy:SELECT relname, relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname = 'order';
C#Test names follow a convention CI can count: any route missing its test fails the build.
/// <summary>/// Quy ước: mỗi route có {id} phải có đúng một test tên/// <c>{Route}_returns_404_for_other_user</c>. Tool tools/AuthzCoverage liệt kê/// route từ EndpointDataSource, đối chiếu với tên test, và fail build nếu thiếu.////// Quy ước đặt tên nghe hình thức, nhưng nó là thứ biến "nhớ viết test authz"/// thành "không viết thì không merge được" — và đó là khác biệt duy nhất giữa/// một dự án có phủ authz và một dự án tin là mình có./// </summary>public class OrderAuthorizationTests : IClassFixture<TwoUserFixture>{    private readonly TwoUserFixture _fx;     public OrderAuthorizationTests(TwoUserFixture fx) => _fx = fx;     [Fact]    public async Task GetOrder_returns_404_for_other_user()    {        // Alice có một đơn thật. Bob là một tài khoản hợp lệ, đăng nhập bình thường.        var aliceOrder = await _fx.SeedOrderFor(_fx.Alice, total: 1_250_000);         var response = await _fx.ClientAs(_fx.Bob)            .GetAsync($"/api/orders/{aliceOrder.Id}");         // 404 chứ không 403: mã trạng thái là một phần của bản vá, không phải chi tiết.        Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);         // Và không có mảnh dữ liệu nào của Alice rò ra qua thân response.        var body = await response.Content.ReadAsStringAsync();        Assert.DoesNotContain("1250000", body);        Assert.DoesNotContain(_fx.Alice.Email, body);    }     [Fact]    public async Task GetOrder_returns_200_for_owner()    {        // Cặp đôi của test trên. Không có nó thì một bản vá "luôn trả 404"        // cũng làm test cross-tenant xanh.        var order = await _fx.SeedOrderFor(_fx.Bob, total: 429_000);         var response = await _fx.ClientAs(_fx.Bob).GetAsync($"/api/orders/{order.Id}");         Assert.Equal(HttpStatusCode.OK, response.StatusCode);    }     /// <summary>    /// BFLA (API5): route admin không có trong UI của người dùng thường, nên nó là    /// route không ai kiểm — và Swagger nói cho bất kỳ ai muốn biết là nó ở đó.    /// </summary>    [Fact]    public async Task DeleteUser_returns_403_for_non_admin()    {        var response = await _fx.ClientAs(_fx.Bob)            .DeleteAsync($"/api/admin/users/{_fx.Alice.Id}");         Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);        Assert.NotNull(await _fx.FindUser(_fx.Alice.Id));   // vẫn còn đó    }}
08

Common mistakes

The "fix"Why it is wrong
Check authorisation in middlewareMiddleware knows who you are, not what 1042 is. It cannot check ownership of a resource it has not read
if (order.UserId != me) throw; after the queryRight behaviour, wrong position: delete the line and the happy path stays green. A fix that tests do not miss when removed is a fix that gets removed
Hide the button in the UIThe endpoint still accepts the request. That is not a fix, it is a CSS change
Switch to UUIDsMakes bulk enumeration far harder, but IDs still leak via email, logs and shared URLs. Layer 3, not layer 1
Return 403 for someone else's resourceLeaks existence. A loop that distinguishes 403 from 404 is a resource map
Require only that the user be logged inExactly Peloton's first fix: every free account could still read everything
Check at the root GraphQL resolverNested field resolvers bypass it. Every node returning user-owned data needs its own check

The process mistake, which outweighs every row above: treating authorisation as code review's job. Code review finds bugs present in the diff; a missing authorisation check is a bug absent from the diff. Noticing what is not there is something humans are bad at — so it has to be the job of method signatures and CI.

The scoping mistake: fixing only the reported endpoint. An IDOR on /orders/{id} almost always has siblings at /invoices/{id} and /orders/{id}/items — same author, same habit.

09

References

Tier 1A01:2021 – Broken Access Control · OWASP · Top 10 · 2021
Tier 1API1:2023 Broken Object Level Authorization · OWASP · API Security Top 10 · 2023
Tier 1API5:2023 Broken Function Level Authorization · OWASP · API Security Top 10 · 2023
Tier 1Row Security Policies · PostgreSQL Global Development Group · PostgreSQL documentation · 16
Tier 2Access control vulnerabilities and privilege escalation · PortSwigger · Web Security Academy
Tier 2Authorization Cheat Sheet · OWASP · Cheat Sheet Series
Part of path
Secure Backend DeveloperView path

Comments

Join the discussion
Sign up to comment

Commenting needs an account with at least one completed lesson. That condition is what keeps this thread worth reading: every point belongs to someone who can be asked back, and reputation accrues over time.

Sign upSign in

You can still read every comment below without an account. Signing in brings you back to this exact spot, not to the top of the page.

Loading comments…