Back to Blog
Security12 min readJun 2026

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.

SecurityAPIAuthorizationIDOR
SB

Sri Balaji

Founder · TheSimplifiedTech

On this page

The bug that ships in every junior API

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.
The one rule under every control in this article

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.

HTTPSWHERE owner = merecord
Client

Untrusted

TLS

Encrypt channel

AuthN

Who are you?

Rate limit

Too many?

Validation

Body well-formed?

AuthZ + ownership

Is it yours?

Handler

Do the work

Database

Scoped query

Audit log

Who did what

Every request runs the gauntlet left to right. Any stage can reject; the handler only runs once all of them pass.

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

ControlWhat it stops
TLS everywhereEavesdropping and tampering, stolen tokens and modified requests on the wire
Authentication on every routeAnonymous access to protected data; relying on "nobody knows the URL"
Object-level authorizationIDOR / BOLA, reading or editing other users' objects by guessing ids
Input validation (allow-list)Injection, type-confusion, and oversized payloads reaching your logic
Rate limitingBrute-force, credential stuffing, scraping, and accidental DoS
No mass assignmentPrivilege escalation by sending fields like `role` or `isAdmin` in the body
Strict CORSMalicious sites making authenticated cross-origin calls on a user's behalf
Generic error responsesInformation 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}")
def get_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
    )
    if not invoice:
        # Leaks the DB error and the SQL to the client
        raise Exception(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

class InvoicePath(BaseModel):
    invoice_id: conint(gt=0)  # input validation: positive int only

@app.get("/api/invoices/{invoice_id}")
def get_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 is None:
        # 404, not 403, don't confirm the row exists to a stranger.
        # Generic message: no SQL, no stack trace, no internal ids.
        raise HTTPException(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)

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

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.