API Security Best Practices

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.

BOLA / IDOR: the number one API bug #

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:

python.python

@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:

python.python

@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.

Broken authentication #

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.

Broken object property level authorization #

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:

python.python

# 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:

python.python

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.

Unrestricted resource consumption #

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.

python.python

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.

Broken function level authorization #

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.

SSRF from user-supplied URLs #

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.

Security misconfiguration #

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.

Input and schema validation #

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:

python.python

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.

API keys vs OAuth #

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.

The call we'd make #

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.