02 · API Security Hardening (CORS, Injection, OWASP API Top 10)¶
Getting auth working (module 1, Level 3) is the start, not the finish. This module covers the specific ways APIs get exploited in production and how to close each one.
CORS: controlling which browsers can call your API¶
Without CORS headers, a browser blocks JavaScript on evil.com from
reading responses from api.example.com — but a misconfigured API can
turn that protection off entirely.
Dangerous — reflects any origin, effectively disabling the protection:
(Browsers actually reject this exact combination, but teams that
dynamically reflect the request's Origin header back recreate the
same hole.)
Correct — an explicit allowlist:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
curl -i -X OPTIONS https://api.example.com/v1/orders \
-H "Origin: https://evil.com" \
-H "Access-Control-Request-Method: POST"
The preflight is rejected because evil.com isn't in the allowlist —
the browser never even sends the real request.
Injection: never trust request data¶
If sort is interpolated directly into SQL, this is catastrophic.
Always use parameterized queries and, per module 2 (Level 2), validate
sort against an allowlist of known-safe column names — never pass
user input into a query string directly, even through an ORM's raw
escape hatches.
# vulnerable
db.execute(f"SELECT * FROM orders ORDER BY {sort_param}")
# safe
ALLOWED_SORTS = {"id", "-id", "created_at", "-created_at"}
if sort_param not in ALLOWED_SORTS:
raise ValidationError("invalid_sort_field")
db.execute("SELECT * FROM orders ORDER BY " + SAFE_SORT_MAP[sort_param])
OWASP API Security Top 10 (selected)¶
Broken Object Level Authorization (BOLA) — the single most common real-world API vulnerability: checking that a token is valid, but not that this user owns this specific resource.
If order 999 belongs to a different user and the API returns it
anyway because it only checked "is this token valid," that's BOLA. The
fix: every resource lookup must filter by the authenticated user's
identity too, not just its own ID:
order = db.orders.find_one(id=999, owner_id=current_user.id)
if order is None:
return 404 # not "belongs to someone else", just not found — don't leak existence
Excessive Data Exposure — returning a full internal object (with a
password hash, internal notes, cost basis) and relying on the client to
ignore fields it doesn't display. Fix: serialize an explicit response
schema, never return jsonify(user.__dict__).
Mass Assignment — accepting a full JSON body and applying it directly to a database model, letting a client set fields it shouldn't:
curl -X PATCH https://api.example.com/v1/users/42 \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "Ada", "is_admin": true}'
If the handler blindly does user.update(**request.json), a client
just made themselves an admin. Fix: an explicit allowlist of
client-settable fields per endpoint.
Security misconfiguration — verbose stack traces in error
responses, debug mode left on in production, default credentials left
active. A 500 response should never include a stack trace to an
external client.
Rate limiting as a security control, not just fairness¶
Beyond the throughput fairness covered in Level 2 module 9, aggressive per-IP and per-account limits on auth endpoints specifically defend against credential stuffing:
Worked example: fixing a BOLA vulnerability¶
A security review finds GET /v1/invoices/{id} returns any invoice by
ID to any authenticated user, not just their own.
- Confirm:
curl .../v1/invoices/17 -H "Authorization: Bearer $OTHER_USER_TOKEN"returns 200 with someone else's invoice. - Fix the query to filter by
owner_id = current_user.id. - Return
404, not403, for another user's invoice ID —403would confirm the ID exists, leaking information. - Add a regression test asserting this specifically, and an automated BOLA scan (varying IDs across authenticated sessions) to CI.
How It Actually Works¶
Hardening measures target specific, well-understood attack mechanics — each defense maps to exactly how the corresponding attack actually executes at the protocol/parsing level.
Input validation against injection: a SQL injection succeeds when untrusted input is concatenated directly into a query string, so the database parses part of the attacker's data as executable SQL syntax rather than as a value. Parameterized queries prevent this mechanically (module 2, Level 2) by sending the value over a separate channel from the query template — the database driver never gives the value's bytes a chance to be interpreted as syntax.
CORS is enforced by the browser, not the server: your server sends
Access-Control-Allow-Origin: https://app.example.com, and it's the
browser's own JavaScript engine that reads this response header and
decides whether to let the calling page's script access the response —
a curl request or a server-to-server call ignores CORS entirely, because
CORS is a browser-side sandboxing mechanism, not an authentication
mechanism.
JWT verification hardening means explicitly checking the alg header
your code accepts before verifying the signature — a well-known real
exploit sends a JWT with alg: none or downgrades from RS256 (asymmetric)
to HS256 (symmetric, using the server's own public key as the HMAC
secret), and a library that blindly trusts the token's own alg field
will "verify" a forged token that was never actually signed by the real
private key. Correct implementations pin the expected algorithm in the
verification call itself, ignoring what the token claims about itself.
Secrets in headers, never URLs: a URL (including its query string) is
routinely logged by proxies, browser history, and server access logs in
plaintext — an API key in ?api_key=... ends up durably persisted in
multiple log files, while the same key in an Authorization header
typically is not, because most default access-log formats capture the
request line and headers list but not header values.
Exercise¶
- Why is reflecting any
Originback withAccess-Control-Allow- Credentials: truedangerous even though it looks similar to a valid allowlist entry? - Explain why a
404is the correct response for another user's resource, rather than a403. - Design the fix for a mass-assignment vulnerability on
POST /v1/orderswhere a client can currently setstatus: "shipped"directly on creation. - What's the difference between validating that a token is valid and validating that a token's owner is authorized for a specific resource? Give an example of an endpoint vulnerable to only the first check.