Text size
Dark mode
RecallRunGuides & benchmarks
Learn to build real systemsGuides•Benchmarks•Experiments•Community
Search the site/Free checklist

Guides/FastAPI

FastAPI: Authorization & API security

Most API breaches are not clever cryptography attacks. Someone changes /orders/1042 to /orders/1043 and gets another customer's order.

In this post

What this post covers
  1. 1. The OWASP API Security Top 10 (2023) → where this guide covers it
  2. 2. Object-level authorization (API1)
  3. 3. Function-level authorization (API5): permissions, not role names
  4. 4. Field-level exposure (API3): different views of one object
  5. 5. Rate limiting (API4, API2, API6)
  6. 6. SSRF: when users give you URLs (API7)
  7. 7. Hardening checklist
  8. 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).

GET /v1/orders/1043 as asha 1 · authenticationwho are you?valid, unexpired token 2 · function levelmay your role callthis endpoint? (API5) 3 · object levelis order 1043 yours?(API1, BOLA) 4 · property levelwhich fields may youread or write? (API3) 401403 404422 / filtered + WWW-Authenticateyou exist, you may not not 403: no existence leakseparate In / Out models

1. The OWASP API Security Top 10 (2023) → where this guide covers it

RiskIn one lineDefence
API1 Broken Object Level Authorizationchanging an id in the URL exposes other users' dataownership checks in every query (section 2)
API2 Broken Authenticationweak tokens, no expiry, credential stuffingpart 17 + rate limits on login
API3 Broken Object Property Level Authorizationexposing or accepting fields a user should not see or setresponse models per role, extra="forbid" (parts 04–05, section 4)
API4 Unrestricted Resource Consumptionhuge pages, uploads, expensive queries, no rate limitscaps on limit, sizes and time, rate limiting (section 5)
API5 Broken Function Level Authorizationregular users reaching admin functionsrole/permission dependencies on routers (section 3)
API6 Unrestricted Access to Sensitive Business Flowsbots buying all the stock, mass sign-upsper-flow limits, CAPTCHA or device checks, anomaly alerts
API7 Server-Side Request Forgeryyour server fetches an attacker-chosen URLURL allow-lists and private-address blocking (section 6)
API8 Security MisconfigurationCORS * with credentials, debug on, verbose errorsparts 09, 12, 23
API9 Improper Inventory Managementforgotten old versions and debug endpointsversioning and deprecation (part 20), no hidden routes
API10 Unsafe Consumption of APIstrusting data from third-party APIsvalidate 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
Make the safe path the easy path

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.

One email to confirm. Unsubscribe any time. What's in it · Privacy

Want the production version of this? See ShipRAG: A production RAG + MCP starter kit for FastAPI: hybrid search, validated citations, evals in CI, Docker, 32 tests.

Written by

Alby Tomy

Python/FastAPI Engineer | Data Engineer | AI Engineer building RAG and MCP systems.

X · GitHub · Contact & enquiries