Learning Goals
3 minBy the end of this lesson you can:
- Apply a ten-point checklist to design or review any API.
- Avoid the API-specific risks: broken object/function authz, data over-exposure, mass assignment.
- Add rate limiting, input validation, safe errors, and locked-down CORS.
- Audit a Flask/FastAPI app against the checklist.
Warm-Up · No UI to Hide Behind
5 minA web page can hide a button; an API can't hide anything. Every endpoint, parameter, and field is directly callable by anyone with the URL. Attackers script against APIs at scale — so each endpoint must defend itself completely. There's even a dedicated OWASP API Security Top 10 because APIs have their own failure modes.
Securing an API is the disciplined application of everything in this level, plus a few API-specific traps (object-level authz, data over-exposure, mass assignment). The deliverable is a checklist you run on every endpoint: encrypt, authenticate, authorize per-object, validate input, limit rate, return only what's needed, fail safely, lock CORS, version, and log. Treat the checklist as a gate, not a suggestion.
New Concept · The Ten-Point Checklist
14 min1. HTTPS only TLS everywhere; redirect/refuse http (L8-09) 2. Authenticate every non-public endpoint (token/session) (L8-37,38) 3. Authorize per object object-level checks, not just "logged in" (L8-28,40) 4. Validate input types, ranges, lengths; reject bad data (boundary) 5. Rate limit per client/IP/key to stop abuse + brute force (L8-37) 6. Don't over-expose data return only needed fields; no internal IDs/PII 7. Safe errors generic messages; no stack traces/SQL (L8-34) 8. Lock down CORS allow-list origins; never reflect arbitrary Origin 9. Version + deprecate /v1, /v2; retire insecure versions safely 10. Log + monitor auth failures, anomalies; alert (L8-25,45)
The API-specific traps (worth calling out)
- Broken object-level authz (BOLA/IDOR) — #1 API risk:
/orders/124returns anyone's order. Always check the object belongs to the caller (Lesson 28). - Excessive data exposure — returning the whole DB row (incl.
password_hash, internal flags) and trusting the client to hide it. Return a minimal response schema. - Mass assignment — binding the whole request body to your model lets a user set fields they shouldn't (
{"name":"x","role":"admin"}). Accept an explicit allow-list of fields.
Input validation & minimal responses
# validate at the boundary; here with a simple schema (pydantic/marshmallow in real apps) def validate_create_user(body: dict) -> dict: name = body.get("name", "") if not isinstance(name, str) or not (1 <= len(name) <= 50): raise ValueError("invalid name") # ✓ mass-assignment defence: only accept allow-listed fields, set role server-side return {"name": name, "role": "reader"} # ✓ excessive-exposure defence: serialise only safe fields def public_user(row) -> dict: return {"id": row["id"], "name": row["name"]} # NOT pw_hash, NOT internal flags
Rate limiting & safe errors
import time from collections import defaultdict from flask import jsonify buckets = defaultdict(list) def rate_limit(key, limit=100, window=60) -> bool: now = time.time() buckets[key] = [t for t in buckets[key] if t > now - window] buckets[key].append(now) return len(buckets[key]) > limit @app.errorhandler(Exception) def safe_error(e): app.logger.exception("api error") # detail → log (L8-45) return jsonify(error="internal error"), 500 # generic → client (no leak)
CORS — don't reflect arbitrary origins
# ✗ DANGEROUS: allow any site to call your authenticated API # resp.headers["Access-Control-Allow-Origin"] = request.headers["Origin"] # ✓ allow-list specific trusted origins only ALLOWED_ORIGINS = {"https://app.example.com"} @app.after_request def cors(resp): origin = request.headers.get("Origin") if origin in ALLOWED_ORIGINS: resp.headers["Access-Control-Allow-Origin"] = origin return resp
Reflecting the request's Origin back (or using * with credentials) lets a malicious site make authenticated requests on a victim's behalf and read the responses. Always allow-list specific origins, and never combine Allow-Origin: * with Allow-Credentials: true.
Worked Example · An API Security Auditor
12 minGoal: a tool that checks a running API (your own) against the checklist — TLS, auth required, rate limiting, safe errors, CORS, no data over-exposure — and scores it.
import requests def audit_api(base: str, protected_path: str, token: str | None) -> None: findings = [] # 1. HTTPS only if base.startswith("http://"): findings.append("🔴 not HTTPS — use TLS (L8-09)") # 2/3. auth required on protected endpoints r = requests.get(base + protected_path, timeout=10) # no token if r.status_code not in (401, 403): findings.append(f"🔴 {protected_path} reachable WITHOUT auth ({r.status_code})") # 5. rate limiting present? codes = [requests.get(base + "/", timeout=10).status_code for _ in range(120)] if 429 not in codes: findings.append("🟡 no 429 after 120 rapid requests — add rate limiting") # 7. safe errors — trigger one, check for stack-trace leakage err = requests.get(base + "/__definitely_missing__", timeout=10) if "Traceback" in err.text or "sqlite" in err.text.lower(): findings.append("🔴 error response leaks internals (stack/SQL) — generic errors") # 6. data over-exposure — does a user object include sensitive fields? if token: me = requests.get(base + "/api/me", timeout=10, headers={"Authorization": f"Bearer {token}"}).json() for bad in ("pw", "password", "pw_hash", "password_hash"): if bad in me: findings.append(f"🔴 /api/me exposes '{bad}' — minimal response schema") # 8. CORS reflection cors = requests.get(base + "/", timeout=10, headers={"Origin": "https://evil.example"}) if cors.headers.get("Access-Control-Allow-Origin") == "https://evil.example": findings.append("🔴 CORS reflects arbitrary Origin — allow-list origins") print(f"API audit of {base} — {len(findings)} issue(s):") for f in findings: print(" ", f) if not findings: print(" ✓ passes the checks performed") audit_api("http://127.0.0.1:5000", "/api/orders/1", token=None)
API audit of http://127.0.0.1:5000 — 3 issue(s): 🔴 not HTTPS — use TLS (L8-09) 🔴 /api/orders/1 reachable WITHOUT auth (200) 🟡 no 429 after 120 rapid requests — add rate limiting
Read the code
The auditor turns the checklist into active probes against your own API: it confirms TLS, that protected endpoints reject unauthenticated calls, that rapid requests get rate-limited, that errors don't leak internals, that user responses don't over-expose sensitive fields, and that CORS doesn't reflect arbitrary origins. Run it in CI (Level 7) and a regression — say, an endpoint that loses its auth check — fails the build. It encodes the whole level: each finding maps to a defence you've built. (Point it only at APIs you own/are authorised to test.)
Try It Yourself
13 minTest against your own API (the Lesson 41 blog, extended with API routes) or a local demo — never a third-party API.
Take an API you built and walk all ten points by hand, marking pass/fail with evidence. Identify your top three gaps.
Find an endpoint that returns a full DB row and add a minimal response serializer. Find one that binds the whole request body and restrict it to an allow-list of fields (set role/owner server-side). Confirm a {"role":"admin"} in the body is ignored.
Add rate limiting, generic error handling, and origin-allow-listed CORS to your API. Re-run the auditor and drive findings to zero (or document why a 🟡 is acceptable).
Hint
# minimal response + no mass assignment: @app.post("/api/users") def create(): body = request.get_json() # accept ONLY name; role decided by server user = {"name": str(body.get("name",""))[:50], "role": "reader"} ... return jsonify(id=new_id, name=user["name"]) # no pw, no role echo
Mini-Challenge · A CI API-Security Gate
8 minTurn the auditor into a CI gate (Level 7): spin up your API, run the checks, and exit non-zero if any 🔴 finding is present (🟡 warns). This fails the build if someone accidentally removes an auth check, leaks errors, or opens CORS — catching security regressions before they ship.
Show the approach
import sys, subprocess, time, requests def gate(base="http://127.0.0.1:5000") -> int: critical = [] # protected endpoint must require auth: if requests.get(base + "/api/orders/1").status_code not in (401, 403): critical.append("orders endpoint reachable without auth") # error must not leak internals: if "Traceback" in requests.get(base + "/__nope__").text: critical.append("error responses leak stack traces") # CORS must not reflect arbitrary origins: h = {"Origin": "https://evil.example"} if requests.get(base + "/", headers=h).headers.get( "Access-Control-Allow-Origin") == "https://evil.example": critical.append("CORS reflects any origin") if critical: print("❌ API SECURITY GATE FAILED:") for c in critical: print(" -", c) return 1 print("✅ API security gate passed") return 0 sys.exit(gate())
Non-negotiables: runs against your own API, fails the build (exit 1) on critical findings (missing auth, error leakage, open CORS).
Recap
3 minAPIs are pure attack surface — every endpoint defends itself. The ten-point checklist: HTTPS only, authenticate, authorize per object, validate input, rate limit, don't over-expose data, safe errors, lock down CORS, version, and log/monitor. Watch the API-specific traps: broken object-level authz (IDOR/BOLA — the #1 API risk), excessive data exposure (return minimal schemas), and mass assignment (accept allow-listed fields; set role/owner server-side). Encode the checklist as an auditor and a CI gate so regressions fail the build. This pulls the whole level into one reusable reference for every API you'll build.
Vocabulary Card
- BOLA / object-level authz
- Checking the caller owns the specific object — the top API risk.
- excessive data exposure
- Returning more fields than needed (e.g. password hashes) — use minimal schemas.
- mass assignment
- Binding the whole request body to a model, letting users set forbidden fields.
- CORS
- Cross-Origin Resource Sharing — allow-list origins; never reflect arbitrary ones.
Homework
4 minAdd JSON API routes to your Lesson 41 blog and harden them against the full checklist (auth, object authz, validation, rate limit, minimal responses, safe errors, CORS). Build the auditor + CI gate and drive critical findings to zero. Write a short note on the three API-specific traps (BOLA, over-exposure, mass assignment) with the fix for each.
Sample · the three API traps & fixes
1. BOLA / object-level authz (IDOR for APIs) — the #1 API risk.
Trap: GET /api/orders/124 returns order 124 to anyone authenticated.
Fix: on every object access, verify order.user_id == caller (or
admin); return 404 otherwise. (Same as L8-28, applied per request.)
2. Excessive data exposure.
Trap: returning the full user row, incl. password_hash and internal
flags, trusting the client to hide them.
Fix: a minimal serializer — public_user(row) returns only
{id, name}. Never let sensitive fields reach the response.
3. Mass assignment.
Trap: User(**request.json) binds EVERY field — a body of
{"name":"x","role":"admin","is_verified":true} self-promotes.
Fix: accept an explicit allow-list of fields; set role/owner/
verified server-side, ignore them in the body.
My auditor + CI gate now pass; protected routes 401 without a token,
errors are generic, CORS is allow-listed, and /api/me leaks nothing.Non-negotiables: hardened API routes passing the auditor/CI gate, and a clear explanation of BOLA, over-exposure, and mass assignment with fixes.