Most API breaches aren't exotic exploits. They're missing authorization checks on endpoints that already require a login and a valid token.
The most common API breach I get called about isn't clever. Someone authenticated correctly, got a valid token, and then requested /api/invoices/8842 when their own invoice was 8830. The server checked that they were logged in, returned the record, and shipped another customer's billing data. No exploit chain, no zero-day. Just a missing ownership check.
That pattern sits at the top of the OWASP API Security Top 10 for a reason. APIs fail differently from server-rendered apps: the client is untrusted, object IDs are exposed in URLs, and every endpoint is a directly reachable surface. This is one slice of application security best practices, focused on the API layer.
Broken Object Level Authorization (also called IDOR) means the API confirms who you are but not what you're allowed to touch. Authentication answers "are you logged in." Authorization answers "is this record yours." Skipping the second check is the single most exploited API flaw in production.
Here's the vulnerable handler. It trusts the ID in the path:
@app.get("/api/invoices/{invoice_id}")
def get_invoice(invoice_id: int, user=Depends(current_user)):
# user is authenticated, so this feels safe. It isn't.
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
if not invoice:
raise HTTPException(404)
return invoice
Any logged-in user can enumerate invoice_id and read every invoice in the system. The fix is to scope the query to the caller so ownership is enforced in the same statement that fetches the row:
@app.get("/api/invoices/{invoice_id}")
def get_invoice(invoice_id: int, user=Depends(current_user)):
invoice = db.query(Invoice).filter(
Invoice.id == invoice_id,
Invoice.owner_id == user.id, # ownership is part of the lookup
).first()
if not invoice:
raise HTTPException(404) # 404, not 403, to avoid leaking existence
return invoice
Two details matter. Scope the query itself rather than fetching first and checking after, so the check can't be forgotten. And return 404 rather than 403 for objects the caller doesn't own, otherwise you confirm which IDs exist. For anything beyond simple ownership, put the decision in one authorization function every handler calls, never copy-pasted per route.
Validate tokens, don't just decode them: A JWT is signed, but signature verification only helps if you actually verify it, check the exp claim, confirm the aud and iss, and reject the alg: none trick. Plenty of libraries decode without verifying by default. Read the flags.
Keep tokens short-lived: Access tokens should live minutes, not weeks. Use refresh tokens for longevity so a leaked access token has a small blast radius. Store secrets used to sign tokens in a secrets manager, not in config files. The deeper mechanics live in authentication and session security.
This is mass assignment. A user is allowed to edit an object, but not every field on it. Binding the request body straight to your model lets a caller set fields they should never control:
# Vulnerable: user can send {"role": "admin", "credit": 99999}
user.update(**request.json())
Allowlist the fields a caller may write, and never derive privilege or balances from client input:
ALLOWED = {"display_name", "timezone", "avatar_url"}
patch = {k: v for k, v in request.json().items() if k in ALLOWED}
user.update(**patch)
The same applies on the way out. Don't serialize the whole record; return an explicit response schema so password hashes and internal flags don't leak.
An endpoint with no limits is a denial-of-service vector and a cost bomb. Two controls do most of the work: rate limiting per client, and hard caps on pagination.
from slowapi import Limiter
limiter = Limiter(key_func=lambda r: r.state.user_id or r.client.host)
@app.get("/api/search")
@limiter.limit("30/minute")
def search(q: str, limit: int = 20):
limit = min(limit, 100) # cap it; never trust client page size
return run_search(q, limit=limit)
Cap request body size, restrict how many items a batch endpoint accepts, and set query timeouts so one caller can't exhaust the connection pool.
BOLA is about objects; this is about operations. Admin and privileged endpoints often rely on the UI hiding the button rather than the server enforcing the role. The URL is guessable. Enforce role checks server-side on every privileged route, ideally with a decorator or middleware, and default-deny: a new route with no explicit policy should reject, not allow.
Any feature that fetches a URL the user provided (webhooks, image imports, link previews) can be turned inward to hit 169.254.169.254 or internal services. Validate the destination: resolve the hostname, reject private and loopback ranges, allowlist schemes and ports, and disable redirects to re-check after each hop. In cloud environments, pin the metadata endpoint behind IMDSv2 and network policy. Reducing static credentials in the first place, covered in keyless cloud authentication, limits what an SSRF can steal.
Verbose errors: Stack traces and SQL fragments in responses hand attackers your schema. Return generic errors to clients, log the detail server-side.
Missing auth on some routes: Health checks, debug endpoints, and newly added routes slip out unauthenticated. Enforce auth as a default middleware and opt specific routes out, rather than opting each route in.
Transport: Serve everything over HTTPS with modern TLS, enable HSTS, and reject plaintext. An API key sent over HTTP is a key you've already leaked.
Validate against a schema at the edge before any handler runs. Reject unknown fields, enforce types and ranges, and bound string lengths. Schema validation kills a whole class of injection and type-confusion bugs cheaply:
from pydantic import BaseModel, EmailStr, constr
class CreateUser(BaseModel, extra="forbid"): # reject unexpected fields
email: EmailStr
display_name: constr(max_length=80)
age: int | None = None
Parameterize every database query, and never log secrets: scrub tokens, keys, and passwords out of request logs before they hit disk, or they become a breach waiting for whoever reads the log store.
Use API keys for server-to-server calls where the caller is the client: long-lived, scoped, rotatable, revocable. Use OAuth (with short-lived access tokens) when a user delegates access to a third party, so you're not handing out standing credentials tied to a person. Don't put API keys in query strings; they end up in access logs and browser history.
Start with authorization, because that's where the real breaches are. If I audit one thing on an API, it's whether every object lookup is scoped to the caller and every privileged route enforces its role server-side. Layer in short-lived tokens, allowlisted input and output fields, rate limits with pagination caps, SSRF guards on any URL fetch, and schema validation at the edge. None of it is exotic. The APIs that get breached aren't missing a firewall; they're missing an owner_id in a WHERE clause.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
A working engineer's tour of the OWASP Top 10, each risk paired with the concrete fix that actually closes it in production.
A likelihood-ordered checklist for tracing "Permission denied" on Linux through mode bits, ownership, ACLs, SELinux, and mount options.
Explore more articles in this category
Run only the CI jobs a change actually affects using if conditionals, trigger path filters, and per-job path detection in a monorepo.
A prioritized toolkit for cutting CI time: measure the critical path first, then cache, parallelize, run only what changed, and shrink the work itself.
Stop stashing long-lived AWS access keys in GitHub secrets and let OIDC hand your workflows short-lived, scoped credentials instead.
Evergreen posts worth revisiting.