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: 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 ordercurrent_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.
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.
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.
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
| Area | What to do | Why |
|---|---|---|
| HTTPS | Serve only over HTTPS; redirect HTTP. Most hosts give free certificates | Stops anyone on the network reading tokens and passwords |
| CORS | Allow only your own frontend origins, never * together with credentials | CORS controls which websites' JavaScript may call your API from a browser |
| Secrets | Keep keys and passwords in environment variables or a secrets manager, never in Git | Leaked repos are a common way keys are stolen |
| Error messages | Return a generic message; log the details on the server | Stack traces reveal your libraries, file paths and queries |
| Debug mode | Turn it off in production | Debug pages can expose code and settings |
| Dependencies | Update packages; run a vulnerability scan such as pip-audit or npm audit | Known holes in libraries are exploited quickly |
| Passwords | Store only a slow hash (bcrypt, argon2); never plain text or fast hashes like MD5 | A stolen database should not reveal passwords |
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
- Who may call this? Is login required, and is the role checked on the server?
- Does it check that the record belongs to the caller (not just that the caller is logged in)?
- Is every input validated for type, length and range, and is SQL parameterised?
- Does the response include only the fields the client needs?
- 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.
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
- Authentication proves who you are; authorisation decides what you may do. Check both on the server for every request.
- Always verify that the requested record belongs to the caller (prevents BOLA/IDOR).
- Use parameterised queries or an ORM. Never build SQL, shell commands or filters from raw input.
- Use separate input and output models: accept only expected fields, return only needed fields, compute prices and roles on the server.
- Add rate limits, size limits and timeouts, and return 429 with Retry-After when limits are hit.
- 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.