DEV Community

Cover image for Secure API Design: Building APIs That Resist Abuse
Sri Balaji
Sri Balaji

Posted on Originally published at thesimplifiedtech.com

Secure API Design: Building APIs That Resist Abuse

TL;DR: The server must trust nothing the client sends. Design APIs that authenticate every route, enforce ownership server-side, and validate input, so an attacker changing one ID in a URL cannot read someone else's data.

Contents

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.

In the real world In tech
A boarding pass proves who you are Authentication, a verified token identifies the caller
Your seat assignment is yours, not whoever's number you shout Authorization, 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 minute Rate limiting, throughput is capped to stop abuse and scraping
The jet bridge is sealed end to end TLS, 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.

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

# 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
Enter fullscreen mode Exit fullscreen mode

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

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
Enter fullscreen mode Exit fullscreen mode

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

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 });
});
Enter fullscreen mode Exit fullscreen mode

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


Originally published on TheSimplifiedTech, where this guide is interactive, with in-browser terminal labs and diagrams. Learn cloud and DevOps by doing, no videos.

Top comments (0)