In this post
What this post covers
- 1. The OWASP API Security Top 10 (2023) → where this guide covers it
- 2. Object-level authorization (API1)
- 3. Function-level authorization (API5): permissions, not role names
- 4. Field-level exposure (API3): different views of one object
- 5. Rate limiting (API4, API2, API6)
- 6. SSRF: when users give you URLs (API7)
- 7. Hardening checklist
- Recap
Most API breaches are not clever cryptography attacks. Someone changes /orders/1042 to
/orders/1043 and gets another customer’s order. Or a customer calls an admin endpoint nobody
protected, or requests a million rows, or makes your server fetch http://169.254.169.254/.
Authorization (who may do what to which object) is where APIs fail most, and the OWASP API
Security Top 10 is the checklist. This part implements the defences that matter most, each running.
An office building's key cards
Your badge gets you into the building (authentication). That does not mean it opens every office. Each door checks whether your badge may open this door (object-level authorization). Some floors are managers only (function-level). And the badge system logs and limits how often anyone can try doors (rate limiting).
1. The OWASP API Security Top 10 (2023) → where this guide covers it
| Risk | In one line | Defence |
|---|---|---|
| API1 Broken Object Level Authorization | changing an id in the URL exposes other users' data | ownership checks in every query (section 2) |
| API2 Broken Authentication | weak tokens, no expiry, credential stuffing | part 17 + rate limits on login |
| API3 Broken Object Property Level Authorization | exposing or accepting fields a user should not see or set | response models per role, extra="forbid" (parts 04–05, section 4) |
| API4 Unrestricted Resource Consumption | huge pages, uploads, expensive queries, no rate limits | caps on limit, sizes and time, rate limiting (section 5) |
| API5 Broken Function Level Authorization | regular users reaching admin functions | role/permission dependencies on routers (section 3) |
| API6 Unrestricted Access to Sensitive Business Flows | bots buying all the stock, mass sign-ups | per-flow limits, CAPTCHA or device checks, anomaly alerts |
| API7 Server-Side Request Forgery | your server fetches an attacker-chosen URL | URL allow-lists and private-address blocking (section 6) |
| API8 Security Misconfiguration | CORS * with credentials, debug on, verbose errors | parts 09, 12, 23 |
| API9 Improper Inventory Management | forgotten old versions and debug endpoints | versioning and deprecation (part 20), no hidden routes |
| API10 Unsafe Consumption of APIs | trusting data from third-party APIs | validate upstream responses with Pydantic too; timeouts (part 10) |
2. Object-level authorization (API1)
the vulnerable endpoint, and the fix: scope every query by the caller
from typing import Annotated
from fastapi import Depends, FastAPI, Header, HTTPException
from fastapi.testclient import TestClient
ORDERS = {1042: {"id": 1042, "customer_id": 7, "total": 1250},
1043: {"id": 1043, "customer_id": 9, "total": 88_000, "address": "22 Palm Grove, Kochi"}}
TOKENS = {"t-asha": {"id": 7, "role": "customer"}, "t-ops": {"id": 1, "role": "support"}}
def current_user(x_token: Annotated[str, Header()]):
if x_token not in TOKENS:
raise HTTPException(401, "not authenticated")
return TOKENS[x_token]
User = Annotated[dict, Depends(current_user)]
app = FastAPI()
@app.get("/v0/orders/{order_id}") # VULNERABLE: authenticated, but not authorized
def get_order_vulnerable(order_id: int, user: User):
return ORDERS.get(order_id) or {}
def find_order_for(user: dict, order_id: int) -> dict:
order = ORDERS.get(order_id)
visible = order is not None and (order["customer_id"] == user["id"] or user["role"] == "support")
if not visible:
raise HTTPException(404, "order not found") # 404, not 403: do not confirm it exists
return order # in SQL: WHERE id = :id AND customer_id = :user_id
@app.get("/v1/orders/{order_id}")
def get_order(order_id: int, user: User):
return find_order_for(user, order_id)
client = TestClient(app)
asha = {"X-Token": "t-asha"}
print("v0, Asha reads order 1043:", client.get("/v0/orders/1043", headers=asha).json()) # leaks!
print("v1, Asha reads order 1043:", client.get("/v1/orders/1043", headers=asha).status_code)
print("v1, Asha reads her order: ", client.get("/v1/orders/1042", headers=asha).json()["total"])
print("v1, support reads 1043: ", client.get("/v1/orders/1043", headers={"X-Token": "t-ops"}).status_code)
Output
v0, Asha reads order 1043: {'id': 1043, 'customer_id': 9, 'total': 88000, 'address': '22 Palm Grove, Kochi'}
v1, Asha reads order 1043: 404
v1, Asha reads her order: 1250
v1, support reads 1043: 200
Put the check in the repository or service method that loads the object (get_for_user(user,
order_id)), not in each endpoint, where one forgotten line reopens the hole. Use unguessable public ids
(UUIDs) as defence in depth, but never as the only protection. Write a test per endpoint that tries another
user's id.
3. Function-level authorization (API5): permissions, not role names
roles map to permissions; endpoints require permissions
from typing import Annotated
from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException
from fastapi.testclient import TestClient
ROLE_PERMISSIONS = {
"customer": {"orders:read:own", "orders:create"},
"support": {"orders:read:any", "refunds:create"},
"admin": {"orders:read:any", "refunds:create", "users:manage", "reports:export"},
}
USERS = {"t-asha": ("asha", "customer"), "t-meera": ("meera", "support"), "t-ravi": ("ravi", "admin")}
def current_user(x_token: Annotated[str, Header()]):
name, role = USERS.get(x_token, (None, None))
if name is None:
raise HTTPException(401, "not authenticated")
return {"name": name, "permissions": ROLE_PERMISSIONS[role]}
def require(permission: str):
def check(user: Annotated[dict, Depends(current_user)]):
if permission not in user["permissions"]:
raise HTTPException(403, f"missing permission {permission}")
return user
return check
admin = APIRouter(prefix="/admin", dependencies=[Depends(require("users:manage"))]) # the whole area
@admin.get("/users")
def list_users():
return ["asha", "meera", "ravi"]
app = FastAPI()
app.include_router(admin)
@app.post("/refunds")
def refund(user: Annotated[dict, Depends(require("refunds:create"))]):
return {"approved_by": user["name"]}
client = TestClient(app)
for token in ("t-asha", "t-meera", "t-ravi"):
h = {"X-Token": token}
print(f"{token:8} refund: {client.post('/refunds', headers=h).status_code} admin/users: {client.get('/admin/users', headers=h).status_code}")
Output
t-asha refund: 403 admin/users: 403
t-meera refund: 200 admin/users: 403
t-ravi refund: 200 admin/users: 200
4. Field-level exposure (API3): different views of one object
customers and staff get different response models
from fastapi import FastAPI, Header
from fastapi.testclient import TestClient
from pydantic import BaseModel
class ProductPublic(BaseModel):
sku: str
name: str
price: float
class ProductStaff(ProductPublic):
cost_price: float
supplier: str
margin_pct: float
ROW = {"sku": "TEA-01", "name": "Assam Tea", "price": 240.0, "cost_price": 131.0, "supplier": "Hill Estates",
"margin_pct": 45.4}
app = FastAPI()
@app.get("/products/{sku}", response_model=ProductStaff | ProductPublic)
def product(sku: str, x_role: str = Header("customer")):
model = ProductStaff if x_role == "staff" else ProductPublic # choose the view explicitly
return model.model_validate(ROW)
client = TestClient(app)
print("customer:", client.get("/products/TEA-01").json())
print("staff: ", client.get("/products/TEA-01", headers={"X-Role": "staff"}).json())
Output
customer: {'sku': 'TEA-01', 'name': 'Assam Tea', 'price': 240.0}
staff: {'sku': 'TEA-01', 'name': 'Assam Tea', 'price': 240.0, 'cost_price': 131.0, 'supplier': 'Hill Estates', 'margin_pct': 45.4}
5. Rate limiting (API4, API2, API6)
a sliding-window limiter as a dependency, with Retry-After
import time
from collections import defaultdict, deque
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi.testclient import TestClient
class SlidingWindowLimiter:
"""In-memory, per process. For a fleet, keep the counters in Redis (same algorithm)."""
def __init__(self, limit: int, window_s: float):
self.limit, self.window = limit, window_s
self.hits: dict[str, deque] = defaultdict(deque)
def __call__(self, request: Request, response: Response):
key = f"{request.client.host}:{request.url.path}" # use the user id when authenticated
now = time.monotonic()
q = self.hits[key]
while q and now - q[0] > self.window:
q.popleft()
if len(q) >= self.limit:
retry = max(1, int(self.window - (now - q[0])) + 1)
raise HTTPException(429, "too many requests", headers={"Retry-After": str(retry)})
q.append(now)
response.headers["RateLimit-Limit"] = str(self.limit)
response.headers["RateLimit-Remaining"] = str(self.limit - len(q))
login_limit = SlidingWindowLimiter(limit=5, window_s=60) # strict on credential endpoints
app = FastAPI()
@app.post("/login", dependencies=[Depends(login_limit)])
def login():
return {"ok": False}
client = TestClient(app)
for attempt in range(1, 8):
r = client.post("/login")
print(attempt, r.status_code, r.headers.get("ratelimit-remaining"), r.headers.get("retry-after"))
Output
1 200 4 None
2 200 3 None
3 200 2 None
4 200 1 None
5 200 0 None
6 429 None 60
7 429 None 60
Where to limit
Coarse limits (per IP, per API key) at the gateway or proxy; fine, business-aware limits (per user, per flow such as "5 OTP requests per hour") in the app, backed by Redis so all workers share the counters.
Also cap work per request
Page sizes (le=100), upload sizes, request timeouts, query timeouts, and GraphQL-style depth limits. A single request should never be able to consume unbounded resources.
6. SSRF: when users give you URLs (API7)
validate webhook URLs: https only, resolved addresses must be public
import ipaddress
from urllib.parse import urlsplit
FAKE_DNS = {"hooks.partner.example": ["93.184.215.14"], # stand-in for socket.getaddrinfo
"evil.example": ["10.0.0.5"], # resolves to an internal address
"metadata.evil": ["169.254.169.254"]}
def resolve(host):
try:
return [str(ipaddress.ip_address(host))] # literal IPs resolve to themselves
except ValueError:
return FAKE_DNS.get(host, [])
def validate_webhook(url: str) -> str:
parts = urlsplit(url)
if parts.scheme != "https":
raise ValueError("webhook must use https")
if parts.port not in (None, 443):
raise ValueError("only port 443 is allowed")
addresses = resolve(parts.hostname or "")
if not addresses:
raise ValueError("host does not resolve")
for addr in addresses:
ip = ipaddress.ip_address(addr)
if not ip.is_global: # private, loopback, link-local, reserved
raise ValueError(f"{parts.hostname} resolves to a non-public address ({ip})")
return url
for url in ["https://hooks.partner.example/orders", "http://hooks.partner.example/x",
"https://127.0.0.1/admin", "https://metadata.evil/latest/meta-data/", "https://evil.example/"]:
try:
print("allowed:", validate_webhook(url))
except ValueError as e:
print("blocked:", url, "→", e)
Output
allowed: https://hooks.partner.example/orders
blocked: http://hooks.partner.example/x → webhook must use https
blocked: https://127.0.0.1/admin → 127.0.0.1 resolves to a non-public address (127.0.0.1)
blocked: https://metadata.evil/latest/meta-data/ → metadata.evil resolves to a non-public address (169.254.169.254)
blocked: https://evil.example/ → evil.example resolves to a non-public address (10.0.0.5)
Resolve and check at send time too, and pin the connection to the address you checked. Otherwise “DNS rebinding” (a hostname that resolves to a public IP when validated and to a private one when fetched) bypasses the check. Disable redirects on outbound calls, or re-validate each hop. Better still, send webhooks from an isolated egress proxy that cannot reach internal networks at all.
7. Hardening checklist
Transport
HTTPS only (HSTS at the proxy); trust X-Forwarded-* only from your proxy (part 23).
Headers
X-Content-Type-Options: nosniff, a strict Cache-Control on personal data, no Server version banners.
Docs
Disable or protect /docs and /openapi.json on public APIs (part 20).
Errors
No stack traces or SQL in responses (part 09); log with request ids instead.
Secrets
From the platform, as SecretStr; rotate them; never log tokens or full card numbers.
Dependencies
Pin versions, scan with pip-audit, and upgrade FastAPI and Starlette promptly: security fixes land there.
Recap
- Object-level checks in the data access path (return 404 for others' objects); test every endpoint with another user's id.
- Permissions, not role names, enforced by dependencies on routers and endpoints.
- Different response models per audience;
extra="forbid"on inputs. - Limit everything: rates (Redis for fleets), page and upload sizes, time. Validate outbound URLs against SSRF.
Get the free RAG production checklist
25 checks to run before real users see your RAG feature: exact-match misses, invented citations, prompt injection through documents, quality that slips after a prompt change. Confirm your email and the PDF comes with the welcome email, plus each new experiment.