August 12, 2026
The OWASP API Top 10, Translated Into Bugs You’ve Actually Written
Your ORM will happily hand an attacker another customer’s row. It’s doing exactly what you asked.

By Daniel Valev
6 min read
A pentester once sent me a screenshot at 11pm. It was GET /invoices/42 with a perfectly valid bearer token — just not the token belonging to anyone at the company that owned invoice 42. Status: 200. The body had another customer's retainer amount and a memo line naming their client.
That endpoint had authentication. It had two approvals on the PR. It had test coverage in the nineties.
What it did not have was a WHERE org_id = ?.
First, make sure you're reading the right list
I need to clear up something I got wrong myself for a few months.
OWASP released a new Top 10 in 2025 — announced at Global AppSec in November 2025 and finalized in January 2026. That is the web application list, where Broken Access Control still sits at #1.
The OWASP API Security Top 10 is a separate project with its own cadence. Its current edition is still 2023, published June 5, 2023. There is no 2025 or 2026 API edition, however many blog posts are titled that way.
So if a compliance checklist told you the API list "moved," check which list actually moved.
Here's the current API ranking:
- Broken Object Level Authorization
- Broken Authentication
- Broken Object Property Level Authorization
- Unrestricted Resource Consumption
- Broken Function Level Authorization
- Unrestricted Access to Sensitive Business Flows
- Server Side Request Forgery
- Security Misconfiguration
- Improper Inventory Management
- Unsafe Consumption of APIs
Categories in that shape are hard to act on. Nobody opens a ticket titled "we have Broken Object Level Authorization." They open one titled "invoices endpoint returned the wrong customer."
Let me translate the top three into code.
API1: Broken Object Level Authorization is a missing WHERE clause
All examples below were written for Python 3.12, FastAPI 0.141.1, SQLAlchemy 2.0.51. I ran every one of them.
Here's the vulnerable route. It authenticates correctly.
# VULNERABLE — authenticated, but never checks who owns the invoice
@app.get("/invoices/{invoice_id}")
def read_invoice(invoice_id: int, db: DbSession, user: CurrentUser):
invoice = db.get(Invoice, invoice_id)
if invoice is None:
raise HTTPException(status_code=404, detail="Invoice not found")
return {"id": invoice.id, "amount_cents": invoice.amount_cents, "memo": invoice.memo}# VULNERABLE — authenticated, but never checks who owns the invoice
@app.get("/invoices/{invoice_id}")
def read_invoice(invoice_id: int, db: DbSession, user: CurrentUser):
invoice = db.get(Invoice, invoice_id)
if invoice is None:
raise HTTPException(status_code=404, detail="Invoice not found")
return {"id": invoice.id, "amount_cents": invoice.amount_cents, "memo": invoice.memo}Every line here is defensible in isolation. There's an auth dependency. There's a 404 for missing rows. Nothing is obviously wrong.
The bug is that db.get(Invoice, invoice_id) is a primary-key lookup, and the primary key comes from the URL.
Here's the fix:
# FIXED — the tenant scope is part of the query, not a separate check
@app.get("/invoices/{invoice_id}")
def read_invoice(invoice_id: int, db: DbSession, user: CurrentUser):
invoice = db.scalar(
select(Invoice).where(
Invoice.id == invoice_id,
Invoice.org_id == user.org_id,
)
)
if invoice is None:
raise HTTPException(status_code=404, detail="Invoice not found")
return {"id": invoice.id, "amount_cents": invoice.amount_cents, "memo": invoice.memo}# FIXED — the tenant scope is part of the query, not a separate check
@app.get("/invoices/{invoice_id}")
def read_invoice(invoice_id: int, db: DbSession, user: CurrentUser):
invoice = db.scalar(
select(Invoice).where(
Invoice.id == invoice_id,
Invoice.org_id == user.org_id,
)
)
if invoice is None:
raise HTTPException(status_code=404, detail="Invoice not found")
return {"id": invoice.id, "amount_cents": invoice.amount_cents, "memo": invoice.memo}Two changes matter more than they look.
The scope moved into the query. It isn't a separate if block that a later refactor can quietly delete while the happy-path tests stay green.
And the failure is 404, not 403. A 403 confirms the row exists, which hands an attacker a free enumeration oracle.
OWASP is explicit that the naive version of this fix doesn't hold: comparing the session's user ID against the ID in the request only covers a narrow subset of cases. Real ownership is usually a graph — user → org → invoice — not an equality check on one field.
One boundary worth keeping straight: if an attacker reaches an endpoint they were never supposed to reach, that's API5 (Broken Function Level Authorization). API1 is when they're legitimately allowed on the endpoint and simply change the ID.
Why your ORM doesn't save you
ORMs are excellent at relationships and completely indifferent to intent.
db.get(Invoice, 42) means "fetch row 42." SQLAlchemy has no notion of which rows this request may see. Neither does GORM, nor Django's Model.objects.get(pk=...). All of them will hand over another tenant's row, because that is precisely what you asked for.
The subtle part is that ORM traversal feels like it enforces scope. user.org.invoices genuinely is scoped — you walked there from an already-authorized object. But when someone flattens that into a direct lookup to kill an N+1, the scope silently disappears and every existing test still passes.
UUIDs don't rescue you either. OWASP does recommend unpredictable IDs, and they raise the cost of enumeration — but an ID that leaked into a log line, a Referer header, or a shared link is still a valid ID. Unpredictability is a speed bump, not an authorization control.
The most durable fix I've used is making scope impossible to omit at the signature level. In Go (1.22, stdlib only) that looks like:
// Vulnerable: the caller may remember to check the org. Most callers won't.
func GetInvoice(id int) (Invoice, error)
// Fixed: orgID is required to call it at all. There is no unscoped version.
func GetInvoiceForOrg(id, orgID int) (Invoice, error)// Vulnerable: the caller may remember to check the org. Most callers won't.
func GetInvoice(id int) (Invoice, error)
// Fixed: orgID is required to call it at all. There is no unscoped version.
func GetInvoiceForOrg(id, orgID int) (Invoice, error)Delete the first function and new code physically cannot forget. Compilers are better at consistency than code review is.
API2: Broken Authentication usually breaks next to the login route
Teams pull in a vetted OAuth library, correctly decide not to hand-roll JWT verification, and then leave the account-recovery flow wide open.
OWASP treats password reset as an authentication endpoint, which is the part people skip. It needs the same lockout and anti-brute-force protection as login — arguably stricter, since it's the flow attackers reach for once they have a token but not a password.
The one I've written myself: letting a user change their email without re-entering their password.
# VULNERABLE — a stolen token becomes permanent account takeover
@app.post("/account/email")
def change_email(payload: EmailChange, db: DbSession, user: CurrentUser):
user.email = payload.new_email
db.commit()
return {"status": "ok"}# VULNERABLE — a stolen token becomes permanent account takeover
@app.post("/account/email")
def change_email(payload: EmailChange, db: DbSession, user: CurrentUser):
user.email = payload.new_email
db.commit()
return {"status": "ok"}Anyone holding a stolen token changes the email, triggers a password reset to an address they control, and now owns the account outright. The token expiring no longer helps.
# FIXED — re-authenticate before a security-relevant change
@app.post("/account/email")
def change_email(payload: EmailChange, db: DbSession, user: CurrentUser):
if not verify_password(payload.current_password, user.password_hash):
raise HTTPException(status_code=403, detail="Password confirmation required")
user.email = payload.new_email
db.commit()
return {"status": "ok"}# FIXED — re-authenticate before a security-relevant change
@app.post("/account/email")
def change_email(payload: EmailChange, db: DbSession, user: CurrentUser):
if not verify_password(payload.current_password, user.password_hash):
raise HTTPException(status_code=403, detail="Password confirmation required")
user.email = payload.new_email
db.commit()
return {"status": "ok"}Sensitive operations need proof of identity, not just proof of session.
API4: Unrestricted Resource Consumption is usually a billing bug
This one rarely shows up as downtime. It shows up on an invoice.
OWASP's own scenario is a cached file that grows past the 15GB cache ceiling to 18GB, so every client pulls from origin — and the monthly bill goes from about US$13 to US$8k. No attacker involved. Just a missing limit.
The version I keep finding in code review is an unbounded pagination parameter:
# VULNERABLE — ?limit=1000000 is a valid request
@app.get("/invoices")
def list_invoices(limit: int = 50, offset: int = 0, db: DbSession = ...):
...# VULNERABLE — ?limit=1000000 is a valid request
@app.get("/invoices")
def list_invoices(limit: int = 50, offset: int = 0, db: DbSession = ...):
...The fix is one annotation, and FastAPI rejects out-of-range values with a 422 before your handler runs:
from typing import Annotated
from fastapi import Query
# FIXED — the cap is enforced at the edge
@app.get("/invoices")
def list_invoices(
limit: Annotated[int, Query(ge=1, le=200)] = 50,
offset: Annotated[int, Query(ge=0)] = 0,
db: DbSession = ...,
):
...from typing import Annotated
from fastapi import Query
# FIXED — the cap is enforced at the edge
@app.get("/invoices")
def list_invoices(
limit: Annotated[int, Query(ge=1, le=200)] = 50,
offset: Annotated[int, Query(ge=0)] = 0,
db: DbSession = ...,
):
...I verified this against FastAPI 0.141.1: ?limit=100 returns 200, ?limit=1000000 and ?limit=-1 both return 422.
Then go set spending limits on every third-party integration you call per-request — SMS, email, biometrics, model inference. If you can't set a hard cap, set a billing alert. The alert is what turns a four-figure surprise into a Slack message.
The test that would have caught it
OWASP's guidance on API1 ends with a line worth taking literally: write tests against the authorization mechanism, and don't ship changes that make them fail.
An authz test is not a happy-path test with a different fixture. It needs a second, hostile identity.
# Written for pytest 9.1.1, httpx 0.28.1, FastAPI 0.141.1
def test_other_tenant_cannot_read_invoice(client):
# Invoice 42 belongs to org 100. User 2 is in org 200.
r = client.get("/invoices/42", headers={"X-User-Id": "2"})
assert r.status_code == 404, "cross-tenant read succeeded — BOLA"
def test_anonymous_cannot_read_invoice(client):
r = client.get("/invoices/42")
assert r.status_code == 401# Written for pytest 9.1.1, httpx 0.28.1, FastAPI 0.141.1
def test_other_tenant_cannot_read_invoice(client):
# Invoice 42 belongs to org 100. User 2 is in org 200.
r = client.get("/invoices/42", headers={"X-User-Id": "2"})
assert r.status_code == 404, "cross-tenant read succeeded — BOLA"
def test_anonymous_cannot_read_invoice(client):
r = client.get("/invoices/42")
assert r.status_code == 401Run that against the vulnerable route and it returns 200 with another tenant's memo line. That's the whole bug, caught in under a second, in CI.
The pattern that made this stick for my team: a shared other_tenant_client fixture, plus a rule that every route touching a tenant-scoped object gets a negative test before merge. Not a security review. A fixture.
One practical note if you're on current versions — Starlette 1.6.0 emits a deprecation warning about using httpx with TestClient. It's noise, not a failure, but it'll show up in your first run.
When this advice doesn't apply
Genuinely single-tenant internal tools. If every authenticated user is legitimately allowed to see every row, a scoped query adds a join and buys nothing. Be honest about whether that's true today and likely to stay true.
404-over-403 has real costs. Returning 404 for forbidden resources makes debugging harder and can confuse legitimate clients handling error states. For internal APIs where enumeration isn't part of the threat model, 403 is clearer and more honest. This is a trade, not a rule.
Scoped queries need the right index. Adding org_id to a hot query without a composite index turns a primary-key lookup into something considerably slower. Check the query plan before you ship it broadly.
Past a certain size, stop doing this in handlers. Once you have dozens of resource types, move to Postgres row-level security or a policy engine like OPA or Cedar. Per-handler checks stop scaling roughly when you can no longer keep the rules in your head.
Takeaways
- Verify which OWASP list you're working from. The web app Top 10 updated to 2025; the API Security Top 10 is still the 2023 edition.
- Put the tenant scope in the query, not in an
if. A separate check is a refactor away from vanishing silently. - Your ORM does not know about authorization. Primary-key lookups return whatever you asked for, UUIDs included.
- Return 404 for objects the caller shouldn't know exist — unless you've deliberately decided enumeration isn't in your threat model.
- Write the negative test with a second identity. If no test in your suite ever authenticates as the wrong user, you have no authorization coverage at all.
The uncomfortable part of that 11pm screenshot was that nothing in our process was missing. We reviewed the code. We had coverage. We just never once asked the API a question it should have refused to answer.
I write weekly about DevOps, backend engineering, and security — follow for the next one.
If this article saved you some debugging or build time, you can support my work on Buy Me a Coffee.