Secure API Design: Building APIs That Resist Abuse
An attacker changes one number in a URL and reads someone else's invoices. Here is how to design APIs that authenticate every route, enforce ownership server-side, validate input, and refuse to leak secrets.
You build an invoices API. A logged-in user fetches their bill at GET /api/invoices/1043. It works, the demo is clean, you ship it. A week later someone is bored and types GET /api/invoices/1044. They are still logged in as themselves, but they are now reading *another customer's* invoice. Name, address, line items, total. Then they write a loop from 1 to 9999 and download everyone's.
Nothing crashed. No error appeared. The code did exactly what it was told: "find invoice 1044 and return it." The problem is what it was *never* told, "...but only if it belongs to the person asking." This is the single most common serious flaw in real APIs, and it is called IDOR (Insecure Direct Object Reference), or BOLA (Broken Object Level Authorization) in the OWASP API Top 10. It sits at number one for a reason.
Who this is for
Developers who can already build a working REST or GraphQL endpoint and now want it to survive a hostile internet. You do not need a security background, if you can read a route handler, you can follow this. We use TypeScript and Python, but the principles are framework-agnostic.
One principle: the server trusts nothing the client sends
The server trusts nothing the client sends. Not the body, not the headers, not the URL, not even the user id the client claims to be.
Every API security control is really one idea applied in a different place. The client is not your code running in a friendly browser, it is *anything that can open a socket*: a script, a proxy, curl, a malicious app. The user id, the resource id, the price field, the isAdmin flag, all of it is just attacker-controllable text until your server independently verifies it.
A boarding pass proves who you areAuthentication, a verified token identifies the caller
Your seat assignment is yours, not whoever's number you shoutAuthorization, ownership checks tie a resource to the caller
Security scans every bag, even frequent flyers'Input validation, every request body is screened, no exceptions
Only so many people board per minuteRate limiting, throughput is capped to stop abuse and scraping
The jet bridge is sealed end to endTLS, the channel is encrypted from client to server
The same airport security model, mapped onto an HTTP request.
The request gauntlet
A secure request runs a gauntlet. Each stage can reject it, and the order matters: cheap checks (is the channel encrypted? is the caller known? are they over their limit?) run before expensive ones (parse the body, hit the database, check ownership). By the time your handler runs, every assumption has already been verified.
Every request runs the gauntlet left to right. Any stage can reject; the handler only runs once all of them pass.
1
TLS terminates
The request arrives over HTTPS. Plaintext on port 80 is redirected to 443. Nothing, tokens, bodies, cookies, travels in the clear.
2
Authenticate the caller
A signed token (session cookie or JWT) is verified. No valid token on a protected route means 401. The server now knows *who* is asking, not who the client claims to be.
3
Check the rate limit
Has this caller exceeded their quota for this window? If so, 429 before any real work happens. This caps brute-force, scraping, and accidental loops.
4
Validate the input
The body and params are parsed against a strict schema. Unknown fields, wrong types, missing required values, or out-of-range numbers are rejected with 400, before they reach business logic.
5
Authorize the action and the object
Two questions: may this *role* do this action, and does this *specific resource* belong to this caller? Failing either is 403 (or 404 to avoid confirming the resource exists).
6
Run the handler
Only now does the handler execute, and it queries the database scoped to the caller's id. Every step gets recorded to an audit log.
The controls, and what each one stops
There is no single "make it secure" switch. Each control closes a specific class of attack. Skip one and you leave that door open, no matter how solid the others are.
Control
What it stops
TLS everywhere
Eavesdropping and tampering, stolen tokens and modified requests on the wire
Authentication on every route
Anonymous access to protected data; relying on "nobody knows the URL"
Object-level authorization
IDOR / BOLA, reading or editing other users' objects by guessing ids
Input validation (allow-list)
Injection, type-confusion, and oversized payloads reaching your logic
Rate limiting
Brute-force, credential stuffing, scraping, and accidental DoS
No mass assignment
Privilege escalation by sending fields like `role` or `isAdmin` in the body
Strict CORS
Malicious sites making authenticated cross-origin calls on a user's behalf
Generic error responses
Information leaks, stack traces, SQL, and internal ids handed to attackers
Map each control to the concrete abuse it prevents.
The insecure endpoint vs. the secure one
Here is the invoices endpoint as it usually ships first. It authenticates (you must be logged in) and then... trusts the id from the URL completely. This is the IDOR.
routes/invoices_insecure.py
python
# INSECURE, do not ship this@app.get("/api/invoices/{invoice_id}")
defget_invoice(invoice_id: int, user=Depends(current_user)):
# We checked the user is logged in (authentication).# We NEVER checked the invoice belongs to them (authorization).
invoice = db.query(
"SELECT * FROM invoices WHERE id = :id", id=invoice_id
)
ifnot invoice:
# Leaks the DB error and the SQL to the clientraiseException(f"no invoice {invoice_id}: query failed")
return invoice # returns ANY user's invoice
Three flaws in nine lines: no ownership check (IDOR), a verbose error that leaks internals, and a query scoped only by id. Now the secure version. The fix is small but the order is everything, validate, then authorize against the *caller's* id, then return a generic error.
routes/invoices.py
python
from fastapi import HTTPException
from pydantic import BaseModel, conint
classInvoicePath(BaseModel):
invoice_id: conint(gt=0) # input validation: positive int only@app.get("/api/invoices/{invoice_id}")
defget_invoice(invoice_id: int, user=Depends(current_user)):
InvoicePath(invoice_id=invoice_id) # 400 on garbage input# Authorization happens IN the query: scope by the caller's id,# not just the resource id. The DB can only ever return# invoices this user owns.
invoice = db.query_one(
"SELECT * FROM invoices WHERE id = :id AND owner_id = :uid",
id=invoice_id, uid=user.id,
)
if invoice isNone:
# 404, not 403, don't confirm the row exists to a stranger.# Generic message: no SQL, no stack trace, no internal ids.raiseHTTPException(status_code=404, detail="Invoice not found")
return invoice
The same shape in TypeScript, and this time guarding against mass assignment on an update. Notice we never spread the request body into the database, we pick exactly the fields a user is allowed to set, and we set owner_id from the verified token, never from the body.
routes/invoices.ts
typescript
import { z } from"zod";
// Allow-list: the ONLY fields a client may send. No role, no// owner_id, no isPaid, those are server-controlled.const UpdateInvoice = z.object({
note: z.string().max(500),
dueDate: z.string().datetime(),
}).strict(); // .strict() rejects unknown keys outright
app.patch("/api/invoices/:id", requireAuth, async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0)
return res.status(400).json({ error: "Bad request" });
const parsed = UpdateInvoice.safeParse(req.body);
if (!parsed.success)
return res.status(400).json({ error: "Bad request" });
// Ownership + update in one scoped statement.const updated = await db.invoice.updateMany({
where: { id, ownerId: req.user.id }, // authZ lives here
data: parsed.data, // only allow-listed fields
});
if (updated.count === 0)
return res.status(404).json({ error: "Invoice not found" });
return res.json({ ok: true });
});
Pro tip
Push authorization *into the query* whenever you can, `WHERE owner_id = :me`. A separate "fetch, then check in code" step is one forgotten `if` away from an IDOR. If the database can only return your rows, the bug is structurally impossible.
Common mistakes that cost hours (or headlines)
Authorization only in the UI. Hiding the "Delete" button does nothing, the endpoint is still one curl away. Every check must live on the server. The client is for convenience, never for security.
IDOR / BOLA. Authenticating but not verifying ownership. If GET /orders/123 works for anyone logged in, you are leaking every order. Scope every query by the caller's id.
Mass assignment. Spreading req.body straight into a model lets a user send "role": "admin" or "balance": 999999. Always allow-list the writable fields explicitly.
Verbose errors. Returning stack traces, SQL, or "user exists but wrong password" hands attackers a map. Log the detail server-side; return a generic message and a correct status code.
No rate limiting. Without it, login endpoints get credential-stuffed and list endpoints get scraped. Cap requests per caller per window, and return 429 when exceeded.
Wide-open CORS.Access-Control-Allow-Origin: * *with* credentials lets any site call your API as your logged-in user. Allow-list specific origins; never reflect the request origin blindly.
Takeaways
The whole article in seven lines
The server trusts nothing the client sends, body, headers, URL, or claimed identity.
Authenticate on every protected route; "unguessable URL" is not authentication.
Authorize the object, not just the action: scope every query by the caller's id to kill IDOR/BOLA.
Validate input with a strict allow-list schema; reject unknown fields and bad types with 400.
Never spread request bodies into your models, allow-list writable fields to stop mass assignment.
Rate-limit, lock down CORS to known origins, and return generic errors with correct status codes.
TLS everywhere. No plaintext, ever.
Where to go next
Secure API design sits at the intersection of identity, input handling, and transport. Each of those is its own deep topic, and each has a companion article on this site.
Authentication vs Authorization, the difference between *who you are* and *what you may touch*, which is exactly the IDOR fix.
Build the muscle memory on the DevOps Engineer path, where these controls show up in real pipelines and deploys.
Want to go deeper?
This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.