Course page
Log in

You are watching a free preview. Enroll to open all 25 lessons, track progress and ask the course tutor.

Enroll for $54.99

Section 1: Thinking about risk as a developer

Broken access control: check on the server, every time

Article 12 min Lesson 2 of 25 Preview

Broken access control means users can act outside their intended permissions: reading another customer's invoice, changing someone else's settings, or reaching admin functions as a normal user. It has topped the OWASP list for years because it is easy to get wrong and frameworks cannot fully prevent it, since only your code knows the business rules.

The typical mistake

@app.get("/invoices/{invoice_id}")
def get_invoice(invoice_id: int, user = Depends(current_user)):
    return db.get(Invoice, invoice_id)    # any logged in user can read any invoice

The user is authenticated, but nobody checked whether this user may see this invoice. Hiding the link in the interface does not help: the server must decide.

The fix: scope queries to the user

@app.get("/invoices/{invoice_id}")
def get_invoice(invoice_id: int, user = Depends(current_user)):
    invoice = db.scalar(
        select(Invoice).where(Invoice.id == invoice_id,
                              Invoice.account_id == user.account_id)
    )
    if invoice is None:
        raise HTTPException(status_code=404)
    return invoice

Filtering by the user's account in the query itself means there is no code path that loads someone else's record. Returning 404 rather than 403 avoids confirming that the record exists.

Principles that prevent most problems

  • Deny by default. Every route requires an explicit permission decision; new routes start closed.
  • Enforce on the server, never only in the user interface or the mobile app.
  • Centralise checks in one authorisation layer or policy library, instead of scattering ad hoc if statements through handlers.
  • Check the object, not just the role. "Is an editor" is not enough; ask "may edit this document".
  • Do not trust identifiers from the client for ownership. Take the user and tenant from the authenticated session, not from a request field.
  • Protect every method. A route that checks permissions on GET but not on PUT or DELETE is a common gap.

Tests that keep it fixed

Write tests for the negative cases, which are the ones most often missing:

def test_user_cannot_read_other_accounts_invoice(client, alice, bobs_invoice):
    r = client.get(f"/invoices/{bobs_invoice.id}", headers=alice.auth)
    assert r.status_code == 404

A small set of "user A cannot touch user B's data" tests for each resource type catches regressions that code review easily misses.

← → switch lessons