Backend Development
ConceptIntermediate 25 min· Production-ready APIs · 16 of 20

API security essentials

Learn the handful of mistakes behind most API breaches: broken access checks, injection, leaked data, missing limits and weak configuration. Fix each one with small, concrete code changes.

Your API is open to the whole internet the moment you deploy it. Bots scan new servers within hours, and real users will send input you never imagined. Security is not a feature you add at the end. It is a set of habits applied to every endpoint.

The good news: most real-world API breaches come from a short list of repeated mistakes. The OWASP API Security Top 10 (a public list kept by the OWASP community) catalogues them. In this lesson you learn the ones you will meet most often and how to prevent each.

Everything here is defensive: it is about protecting your own APIs. Practise attacks only on your own apps or on intentionally vulnerable labs.

Authentication is not authorisation

Two questions get mixed up all the time.

Authentication asks: who are you? (login, token, API key). Authorisation asks: are you allowed to do this specific thing? Think of a hotel. The key card proves you are a guest (authentication). It should only open your room, not every room (authorisation).

How login and tokens work is covered in *Authentication: sessions, JWT and OAuth*. This lesson focuses on what happens after the user is logged in, because that is where most API bugs live.

Broken object-level authorisation (BOLA)

This is the number one issue on the OWASP API list. It is also called IDOR. The pattern: the API takes an ID from the URL and returns that record without checking that it belongs to the caller.

Imagine Brew & Bean has GET /orders/42. Rahul is logged in and owns order 42. If he changes the URL to /orders/43 and sees Priya's name and phone number, the API is broken, even though he was properly logged in. Anyone can write a loop that tries every number.

Vulnerable and fixed handler (FastAPI)
# VULNERABLE: any logged-in user can read any order
@app.get("/orders/{order_id}")
def get_order(order_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)):
    order = db.get(Order, order_id)
    if order is None:
        raise HTTPException(status_code=404, detail="Order not found")
    return order


# FIXED: check ownership on every request
@app.get("/orders/{order_id}")
def get_order(order_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)):
    order = db.get(Order, order_id)
    if order is None or order.user_id != user.id:
        # same answer for "missing" and "not yours", so IDs can't be probed
        raise HTTPException(status_code=404, detail="Order not found")
    return order

current_user and get_db are dependencies you write (see the authentication and ORM lessons). The key line is the ownership comparison.

Make the safe way the easy way

Put the ownership check in one helper such as get_order_for_user(db, order_id, user) and use it in every route. Or filter at query level: select(Order).where(Order.id == order_id, Order.user_id == user.id). A check you must remember to copy into 30 routes will be forgotten in one of them. Using hard-to-guess IDs (UUIDs) helps a little, but it is not a substitute for the check.

A close cousin is broken function-level authorisation: a normal user calls an admin endpoint such as PATCH /orders/{id}/status (which only staff should use) because the API hides the button in the UI but never checks the role on the server. The rule: the server decides, never the frontend.

Injection: never build queries from strings

SQL injection happens when user input becomes part of the SQL text. The database cannot tell your command from the attacker's data.

If the name is ' OR '1'='1, the unsafe query below returns every order. A worse input could delete a table. The fix is old and simple: parameterised queries, where the SQL and the values travel separately.

Unsafe versus safe query
from sqlalchemy import text

# UNSAFE: the f-string pastes input into the SQL
rows = db.execute(
    text(f"SELECT * FROM orders WHERE customer_name = '{name}'")
).fetchall()

# SAFE: :name is a placeholder; the driver sends the value separately
rows = db.execute(
    text("SELECT * FROM orders WHERE customer_name = :name"),
    {"name": name},
).fetchall()

ORM queries such as select(Order).where(Order.customer_name == name) are parameterised for you. The danger is hand-built strings.

The same idea applies elsewhere. Never pass user input to a shell command (os.system, subprocess with shell=True), never build NoSQL filters straight from a request body, and never use eval on input. Treat all input as data, not as code.

Returning too much, accepting too much

Two opposite mistakes share one root cause: trusting the shape of the data.

Excessive data exposure. You return the whole database row, including password_hash, internal notes or other people's phone numbers, and rely on the frontend to hide them. Anyone with a browser's network tab can see everything. Return only what the client needs, using an explicit response model.

Mass assignment. You take the request body and save every field. A user adds "is_admin": true or "total": 1 to the JSON and your code obediently stores it. Accept only the fields you expect, and compute sensitive ones (like an order total) on the server.

Separate input and output models (Pydantic)
from pydantic import BaseModel

class OrderItemIn(BaseModel):
    item_id: str
    qty: int

class OrderIn(BaseModel):          # what the client may send
    customer_name: str
    phone: str
    items: list[OrderItemIn]
    # no total, no status, no id: the server sets these

class OrderOut(BaseModel):         # what the client may see
    id: int
    status: str
    total: int
    pickup_time: str

@app.post("/orders", response_model=OrderOut)
def create_order(body: OrderIn, user: User = Depends(current_user), db: Session = Depends(get_db)):
    total = calculate_total(body.items, db)   # server computes the price
    order = Order(user_id=user.id, status="received", total=total, **body.model_dump(exclude={"items"}))
    ...

Pydantic ignores unknown fields by default, so an extra is_admin in the JSON is dropped. response_model filters the output to OrderOut's fields.

Limits: rate limiting and size limits

Without limits, one script can guess passwords, scrape your whole menu, or run up your cloud bill. Add:

A rate limit per user or IP, especially on login, OTP and password-reset endpoints. Return status 429 Too Many Requests with a Retry-After header.

A maximum request body size and a maximum page size (for example limit capped at 100) so one request cannot ask for a million rows.

A timeout on outgoing calls and database queries so slow requests cannot pile up.

A tiny in-memory rate limiter
import time
from collections import defaultdict, deque

from fastapi import Depends, FastAPI, HTTPException, Request

app = FastAPI()

WINDOW_SECONDS = 60
MAX_REQUESTS = 5
hits: dict[str, deque] = defaultdict(deque)


def rate_limit(request: Request):
    key = request.client.host if request.client else "unknown"
    now = time.monotonic()
    recent = hits[key]
    while recent and now - recent[0] > WINDOW_SECONDS:
        recent.popleft()
    if len(recent) >= MAX_REQUESTS:
        raise HTTPException(
            status_code=429,
            detail="Too many requests",
            headers={"Retry-After": str(WINDOW_SECONDS)},
        )
    recent.append(now)


@app.post("/login", dependencies=[Depends(rate_limit)])
def login():
    return {"ok": True}

Allows 5 calls per minute per IP to /login. The 6th gets 429.

This limiter is for learning

It keeps counts in one process's memory. With several server copies, or after a restart, the counts are lost or split. Real systems keep counters in Redis (see *Caching with Redis*) or use a gateway or a library built for this. Also remember that many users can share one IP (a college or office network), so combine IP with user ID where you can.

Transport, CORS and configuration

Configuration basics

AreaWhat to doWhy
HTTPSServe only over HTTPS; redirect HTTP. Most hosts give free certificatesStops anyone on the network reading tokens and passwords
CORSAllow only your own frontend origins, never * together with credentialsCORS controls which websites' JavaScript may call your API from a browser
SecretsKeep keys and passwords in environment variables or a secrets manager, never in GitLeaked repos are a common way keys are stolen
Error messagesReturn a generic message; log the details on the serverStack traces reveal your libraries, file paths and queries
Debug modeTurn it off in productionDebug pages can expose code and settings
DependenciesUpdate packages; run a vulnerability scan such as pip-audit or npm auditKnown holes in libraries are exploited quickly
PasswordsStore only a slow hash (bcrypt, argon2); never plain text or fast hashes like MD5A stolen database should not reveal passwords
Restrictive CORS in FastAPI
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://brewandbean.example", "http://localhost:5173"],
    allow_methods=["GET", "POST", "PATCH"],
    allow_headers=["Authorization", "Content-Type"],
)

Only these two origins (the production site and the local Vite dev server) can call the API from a browser. CORS is a browser rule: it does not stop curl or scripts, so it is not a replacement for authentication.

Never commit secrets

If a database URL or API key was ever pushed to GitHub, treat it as stolen even if you deleted it later, because Git history and bots keep copies. Rotate it (create a new one and disable the old one). Put .env in .gitignore from the first commit.

A simple way to think: ask five questions per endpoint

Endpoint security review

  1. Who may call this? Is login required, and is the role checked on the server?
  2. Does it check that the record belongs to the caller (not just that the caller is logged in)?
  3. Is every input validated for type, length and range, and is SQL parameterised?
  4. Does the response include only the fields the client needs?
  5. Is there a limit on rate, body size and page size, and are errors generic?

How this comes up in interviews. Common questions: the difference between authentication and authorisation; what SQL injection is and how parameterised queries prevent it; what IDOR or BOLA is and how you would fix it; what CORS is and why it is not a security control for the server; how you store passwords; how you would stop brute-force login attempts. A strong answer gives a short example like the order-ID one above.

Prompt · Security review of my endpoint
Act as an application security reviewer. Review this API endpoint for the OWASP API Security Top 10 issues: broken object-level authorisation, injection, excessive data exposure, mass assignment, missing rate limits and weak error handling.

Framework: [FastAPI / Express]
Who should be allowed to call it: [describe roles]
Code:
[paste your endpoint code]

List each problem with the line it comes from, explain in one sentence how it could be abused, and show the corrected code. Do not invent problems that are not in the code.

What to expect: Check each finding against the code yourself. AI reviewers sometimes miss ownership checks or flag harmless lines.

Try it: Find the three holes

This endpoint has three security problems. Find them and rewrite it.

`` @app.patch("/users/{user_id}") def update_user(user_id: int, body: dict, db: Session = Depends(get_db)): db.execute(text(f"UPDATE users SET name = '{body['name']}', is_admin = {body.get('is_admin', 0)} WHERE id = {user_id}")) db.commit() return {"ok": True} ``

Key takeaways

  1. Authentication proves who you are; authorisation decides what you may do. Check both on the server for every request.
  2. Always verify that the requested record belongs to the caller (prevents BOLA/IDOR).
  3. Use parameterised queries or an ORM. Never build SQL, shell commands or filters from raw input.
  4. Use separate input and output models: accept only expected fields, return only needed fields, compute prices and roles on the server.
  5. Add rate limits, size limits and timeouts, and return 429 with Retry-After when limits are hit.
  6. Use HTTPS, a strict CORS list, secrets in environment variables, generic error messages and updated dependencies.

Check yourself

Finishing the quiz marks the lesson as done.

1. Rahul is logged in and changes /orders/42 to /orders/43 in the URL, and sees someone else's order. Which problem is this?

2. What makes a query safe against SQL injection?

3. A client sends {"customer_name": "Asha", "total": 1} to POST /orders. What should the server do with total?

4. Which statement about CORS is correct?

5. Why is an in-memory rate limiter not enough for a production API running three server copies?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free