Chapter 9: API Security
"Security is not a feature you bolt on after launch.
It is the consequence of every design decision in every previous chapter."
1. Physical Reality First
Your API is exposed to the internet. Every endpoint is a potential entry point. Unlike a desktop application where the attacker must first get onto the machine, your API hands out its attack surface on a silver platter: structured endpoints, documented parameters, predictable IDs.
The numbers are sobering:
- APIs are the #1 attack vector for data breaches (Gartner, 2022)
- 91% of organizations had an API security incident in the past year (Salt Security, 2023)
- The average breach takes 197 days to detect
The most dangerous thing about API attacks: they look like normal traffic. An attacker calling GET /users/1, GET /users/2, GET /users/3... looks like a normal client. Without rate limiting and anomaly detection, you won't know until your entire user database is on the dark web.
This chapter covers the complete API attack surface — systematically, with the exact code patterns that are vulnerable and the exact fixes.
2. OWASP API Security Top 10 (2023)
OWASP (Open Web Application Security Project) maintains a list of the most critical API security risks. These are not theoretical — each one represents real breaches at real companies. Know them all.
API1: Broken Object Level Authorization (BOLA / IDOR)
The most common, most dangerous API vulnerability.
# ❌ Vulnerable — no ownership check
@app.get("/orders/{order_id}")
def get_order(order_id: int, current_user: User):
return db.query(Order).filter(Order.id == order_id).first()
# Any authenticated user can read ANY order by changing order_id
# ✅ Secure — ownership enforced
@app.get("/orders/{order_id}")
def get_order(order_id: int, current_user: User):
order = db.query(Order).filter(
Order.id == order_id,
Order.user_id == current_user.id # ← ownership check
).first()
if not order:
raise HTTPException(status_code=404) # 404, not 403 (don't reveal existence)
return order
Why this happens: Developers implement authentication (is user logged in?) but forget authorization (does this user own this resource?).
Attack: Attacker enumerates IDs — GET /orders/1, /orders/2, ..., /orders/10000 — downloading every order in your system.
Fix: Every endpoint that accesses a resource by ID must include the authenticated user's ID in the database query. No exceptions.
API2: Broken Authentication
# ❌ Vulnerable — JWT not verified
def get_user_from_token(token: str):
payload = jwt.decode(token, options={"verify_signature": False}) # NEVER DO THIS
return payload["user_id"]
# ❌ Vulnerable — algorithm confusion attack
def get_user_from_token(token: str):
payload = jwt.decode(token, PUBLIC_KEY) # accepts RS256 AND HS256
# Attacker uses the public key as the HS256 secret to forge tokens
Chapter 5 covered the full auth system. The most common auth bugs:
- Accepting
alg: nonein JWT (no signature required) - Algorithm confusion: RS256 endpoint accepts HS256 token signed with the public key
- Weak secrets: JWT signed with
"secret"or"password" - No expiry on tokens
- Refresh tokens never invalidated on logout
Fix: Always verify signature. Explicitly specify allowed algorithms. Use secrets with at least 256 bits of entropy. Always check exp claim.
# ✅ Secure JWT verification
payload = jwt.decode(
token,
PUBLIC_KEY,
algorithms=["RS256"], # ← explicit, never ["RS256", "HS256"]
options={"require": ["exp", "iss", "sub"]}
)
API3: Broken Object Property Level Authorization (Mass Assignment)
The vulnerability that broke GitHub in 2012. An attacker added themselves to any organization by exploiting mass assignment.
# ❌ Vulnerable — blindly applies all request body fields to the model
@app.patch("/users/{user_id}")
def update_user(user_id: int, body: dict, current_user: User):
user = db.get(User, user_id)
for key, value in body.items():
setattr(user, key, value) # attacker sends {"role": "admin", "balance": 999999}
db.commit()
# ✅ Secure — explicit allowlist of updatable fields
class UserUpdateSchema(BaseModel):
name: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
# role, balance, is_admin NOT here — cannot be set via this endpoint
@app.patch("/users/{user_id}")
def update_user(user_id: int, body: UserUpdateSchema, current_user: User):
user = db.get(User, user_id)
update_data = body.dict(exclude_unset=True) # only fields client explicitly sent
for key, value in update_data.items():
setattr(user, key, value)
db.commit()
The attack: Client sends fields that should not be user-modifiable — role, is_admin, balance, email_verified, subscription_tier. If your endpoint blindly applies all incoming fields, these get updated.
Fix: Use a schema/DTO (Data Transfer Object) that explicitly declares which fields are allowed. Never use **kwargs or dict unpacking from request body directly onto a model.
API4: Unrestricted Resource Consumption
Your API has no limits. An attacker:
- Creates 10,000 accounts with one script
- Uploads 10GB files to every endpoint that accepts files
- Sends
{"ids": [1,2,3,...,100000]}to a batch endpoint - Triggers 10,000 password reset emails, burning your SendGrid quota
Fixes:
# File upload size limit
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
# Batch request size limit
if len(body.ids) > 100:
raise HTTPException(400, "Maximum 100 IDs per batch request")
# Pagination limit enforcement (Chapter 7)
if limit > 100:
raise HTTPException(400, "Maximum limit is 100")
# Rate limiting (covered in distributed systems book, Chapter 17)
# Every endpoint: token bucket or sliding window per user/IP
This is distinct from rate limiting (which is per-time-window). Resource consumption limits are per-request maximums.
API5: Broken Function Level Authorization
# ❌ Vulnerable — admin endpoint with no role check
@app.delete("/admin/users/{user_id}")
def delete_user(user_id: int, current_user: User):
# Assumes only admins call /admin/* — but any authenticated user can
db.delete(db.get(User, user_id))
# ✅ Secure
@app.delete("/admin/users/{user_id}")
def delete_user(user_id: int, current_user: User):
if current_user.role != "admin":
raise HTTPException(status_code=403)
db.delete(db.get(User, user_id))
The pattern: /admin/* endpoints, DELETE endpoints, bulk operations, reporting endpoints — developers assume only authorized users will call them. Attackers enumerate URLs systematically.
Fix: Every endpoint checks permissions explicitly. "Security through obscurity" (unlisted endpoint = secure) is not security. Use a middleware or decorator:
def require_role(role: str):
def decorator(func):
def wrapper(*args, current_user: User, **kwargs):
if current_user.role != role:
raise HTTPException(403)
return func(*args, current_user=current_user, **kwargs)
return wrapper
return decorator
@app.delete("/admin/users/{user_id}")
@require_role("admin")
def delete_user(user_id: int, current_user: User):
...
API6: Unrestricted Access to Sensitive Business Flows
Some API flows are valid individually but dangerous if automated:
POST /auth/login— brute-force password attacksPOST /checkout— credit card testing (attacker tests stolen cards)POST /referrals— referral reward abuseGET /users?email=test@example.com— account enumeration
Fixes: These require business logic-aware protections:
- CAPTCHA on login after N failures
- Velocity checks: more than 5 checkouts in 1 minute from one IP → flag
- Referral codes: one per account, require email verification
- Don't expose exact error messages that help enumeration (Chapter 6)
API7: Server-Side Request Forgery (SSRF)
# ❌ Vulnerable — server fetches user-provided URL
@app.post("/webhooks/test")
def test_webhook(body: dict):
url = body["url"]
response = requests.get(url) # attacker sends url="http://169.254.169.254/latest/meta-data/"
return response.json()
The attacker makes YOUR server fetch internal AWS metadata, internal services, or localhost admin interfaces. Your server has internal network access that the attacker doesn't.
Attack URLs:
http://169.254.169.254/latest/meta-data/ ← AWS instance metadata (gets IAM credentials)
http://localhost:8080/admin ← internal admin interface
http://10.0.0.5/internal-api ← internal microservice
file:///etc/passwd ← local filesystem
Fix:
import ipaddress, urllib.parse
ALLOWED_SCHEMES = {"http", "https"}
BLOCKED_NETWORKS = [
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("169.254.0.0/16"), # link-local (AWS metadata)
ipaddress.ip_network("127.0.0.0/8"),
]
def is_safe_url(url: str) -> bool:
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in ALLOWED_SCHEMES:
return False
ip = socket.gethostbyname(parsed.hostname)
for network in BLOCKED_NETWORKS:
if ipaddress.ip_address(ip) in network:
return False
return True
API8: Security Misconfiguration
The catch-all for configuration bugs:
❌ Debug mode enabled in production
❌ Default credentials not changed (admin/admin)
❌ Verbose error messages exposing stack traces
❌ CORS: Access-Control-Allow-Origin: * on authenticated endpoints
❌ HTTP instead of HTTPS
❌ Old API versions still running (v1 with known vulnerabilities)
❌ Unnecessary HTTP methods enabled (TRACE, OPTIONS on internal endpoints)
❌ Missing security headers (covered below)
API9: Improper Inventory Management
You don't know what APIs you're running:
- Forgotten staging endpoints accessible on production
- Old API versions (
/v1/) with removed security controls - Shadow APIs built by other teams without security review
- Third-party APIs your app depends on with their own vulnerabilities
Fix: API gateway as the single entry point. API catalog (all endpoints documented). Automated API discovery scanning. Every API version explicitly listed and actively deprecated/sunsetted.
API10: Unsafe Consumption of APIs
Your API calls third-party APIs. If you blindly trust their responses:
# ❌ Vulnerable
response = requests.get("https://third-party-api.com/users")
data = response.json()
db.execute(f"INSERT INTO users VALUES ('{data['name']}')") # SQL injection from third-party data
# ✅ Secure
data = response.json()
validated = UserSchema(**data) # validate before use
db.execute("INSERT INTO users VALUES (?)", [validated.name]) # parameterized query
Fix: Validate and sanitize all data from external APIs exactly as you would from user input.
3. Injection Attacks
3.1 SQL Injection
# ❌ The classic vulnerability
user_input = "' OR '1'='1"
query = f"SELECT * FROM users WHERE email = '{user_input}'"
# Becomes: SELECT * FROM users WHERE email = '' OR '1'='1'
# Returns ALL users
# ✅ Parameterized queries — always, no exceptions
query = "SELECT * FROM users WHERE email = ?"
db.execute(query, [user_input])
Modern ORMs protect you by default:
# SQLAlchemy (parameterized automatically)
db.query(User).filter(User.email == user_input).first() ✅
# But raw SQL in ORMs is still vulnerable
db.execute(f"SELECT * FROM users WHERE email = '{user_input}'") ❌
db.execute("SELECT * FROM users WHERE email = :email", {"email": user_input}) ✅
Never construct SQL strings with user input. Ever. For any reason.
3.2 NoSQL Injection
// ❌ MongoDB — attacker sends {"$gt": ""} as password
const user = await db.collection('users').findOne({
email: req.body.email,
password: req.body.password // attacker sends: {"$gt": ""}
})
// Matches any user with email = that email (password "$gt" "" = always true)
// ✅ Validate input types before query
if (typeof req.body.password !== 'string') {
throw new BadRequest("Invalid password format")
}
Fix: Validate that query parameters are the expected type (string, not object) before using them in DB queries.
3.3 Command Injection
# ❌ Allows arbitrary command execution
import subprocess
filename = request.args.get("filename")
subprocess.run(f"convert {filename} output.pdf", shell=True)
# Attacker sends: filename="; rm -rf / #"
# ✅ Never shell=True with user input
subprocess.run(["convert", filename, "output.pdf"], shell=False)
# Arguments are passed as array — no shell interpretation
4. CORS — Cross-Origin Resource Sharing
4.1 The Same-Origin Policy
Browsers enforce the Same-Origin Policy: JavaScript on https://app.example.com cannot make AJAX requests to https://api.example.com by default. Different origin = blocked.
An origin is: scheme + host + port. https://app.example.com and https://api.example.com are different origins (different host).
4.2 How CORS Works
CORS is the mechanism that lets your API explicitly allow cross-origin requests.
Simple requests (GET, POST with simple headers):
Browser → GET https://api.example.com/products
Origin: https://app.example.com
API → 200 OK
Access-Control-Allow-Origin: https://app.example.com
Browser checks: is my origin in Access-Control-Allow-Origin? If yes, JavaScript gets the response. If no, browser blocks it (the request did reach the server — CORS is a browser enforcement, not server enforcement).
Preflight requests (non-simple requests: DELETE, PATCH, custom headers):
Browser → OPTIONS https://api.example.com/orders/99
Origin: https://app.example.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Authorization, Content-Type
API → 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400 ← cache preflight for 1 day
Browser → DELETE https://api.example.com/orders/99 (actual request)
The preflight is an OPTIONS request the browser sends automatically. Your API must handle it.
4.3 CORS Configuration — Do This Right
# ❌ Dangerous — allows any origin, including evil.com
Access-Control-Allow-Origin: *
# ❌ Dangerous — wildcard + credentials = security nightmare
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
# (browsers actually block this combination — but developers try it)
# ✅ Explicit allowlist
ALLOWED_ORIGINS = [
"https://app.example.com",
"https://admin.example.com",
"https://partner.trusted.com"
]
def cors_middleware(request, response):
origin = request.headers.get("Origin")
if origin in ALLOWED_ORIGINS:
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Vary"] = "Origin" # ← required when origin varies
return response
Access-Control-Allow-Credentials: true allows cookies and Authorization headers to be sent cross-origin. Only set this when you explicitly need it (OAuth flows, cookie-based auth). Never combine with Allow-Origin: *.
4.4 CORS vs Security
Critical misunderstanding: CORS is not a security control on your API. CORS is a browser instruction. A curl command, a mobile app, or any non-browser client ignores CORS headers entirely.
CORS protects against malicious websites making API calls using the browser user's credentials (CSRF-like attacks). It does not protect against direct API attacks.
Implication: Never rely on CORS as your only authorization mechanism. Always validate auth tokens server-side regardless of origin.
5. Input Validation
Validate everything. Trust nothing.
from pydantic import BaseModel, EmailStr, validator, Field
from typing import Optional
import re
class CreateUserRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
email: EmailStr # validates email format
age: int = Field(..., ge=0, le=150)
phone: Optional[str] = None
role: str = Field(default="user") # will be validated below
@validator("name")
def name_must_not_contain_html(cls, v):
if re.search(r"<[^>]+>", v):
raise ValueError("Name cannot contain HTML")
return v.strip()
@validator("role")
def role_must_be_valid(cls, v):
allowed = {"user", "moderator"} # admin not in this list
if v not in allowed:
raise ValueError(f"Role must be one of: {allowed}")
return v
@validator("phone")
def phone_format(cls, v):
if v and not re.match(r"^\+[1-9]\d{1,14}$", v):
raise ValueError("Phone must be in E.164 format: +919876543210")
return v
Validation rules:
- Type: Is it a string, integer, boolean? Not an object or array where you expect a string.
- Length:
max_lengthon strings. Without this, attacker sends 1GB string. - Range:
ge,leon numbers. Without this, attacker sends negative amounts. - Format: Email, phone, URL — use regex or dedicated validators.
- Enum: If a field can only be one of N values, reject anything else.
- Encoding: Strip leading/trailing whitespace. Reject null bytes (
\x00).
Validate at the API boundary. Never assume data in your DB is clean — validate on the way in, not on the way out.
6. Security Headers
HTTP response headers that instruct browsers to enforce additional protections. Set these on every API response:
# Prevent browsers from sniffing content type (MIME confusion attacks)
X-Content-Type-Options: nosniff
# Prevent your API response from being embedded in an iframe (clickjacking)
X-Frame-Options: DENY
# Force HTTPS for future requests (HSTS)
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
# Restrict what resources pages served from your domain can load (if you serve HTML)
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
# Don't send Referer header to third parties (privacy)
Referrer-Policy: strict-origin-when-cross-origin
# Disable browser features your API doesn't need
Permissions-Policy: geolocation=(), camera=(), microphone=()
For pure REST APIs (no HTML), the most critical are:
X-Content-Type-Options: nosniff— alwaysStrict-Transport-Security— always (HTTPS enforcement)X-Frame-Options: DENY— always
HSTS deep-dive:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
This tells the browser: "For the next year (31536000 seconds), always use HTTPS for this domain. Don't even try HTTP." After the first HTTPS response, the browser refuses to make HTTP requests to your domain — no downgrade attacks possible.
preload submits your domain to browsers' built-in HSTS preload list — enforced before the first connection, even on first visit.
7. HTTPS Enforcement
Never accept HTTP for API traffic. HTTPS is non-negotiable.
# Redirect all HTTP to HTTPS at the load balancer/reverse proxy
server {
listen 80;
return 301 https://$host$request_uri;
}
API-level enforcement:
def require_https(request):
if request.scheme != "https":
raise HTTPException(
status_code=301,
headers={"Location": f"https://{request.host}{request.path}"}
)
What HTTPS protects:
- Credentials (
Authorizationheader) from interception - Request/response bodies from reading or modification
- URLs from logging by ISPs (query params are encrypted in HTTPS)
What HTTPS does NOT protect:
- The hostname (
api.example.com) — visible in TLS SNI (Server Name Indication) - Metadata: when you called, how much data was transferred
- Anything after a successful TLS downgrade attack (HSTS prevents this)
8. Secrets Management
# ❌ Secrets in code — committed to git, leaked to everyone with repo access
DB_PASSWORD = "MyP@ssw0rd123"
JWT_SECRET = "super-secret-key"
STRIPE_KEY = "sk_live_4eC39HqLyjWD"
# ✅ Secrets in environment variables or secret manager
import os
DB_PASSWORD = os.environ["DB_PASSWORD"]
JWT_SECRET = os.environ["JWT_SECRET"]
# ✅ Even better: secret manager (AWS Secrets Manager, HashiCorp Vault)
import boto3
secret = boto3.client("secretsmanager").get_secret_value(SecretId="prod/api/jwt_secret")
JWT_SECRET = secret["SecretString"]
Never commit secrets to git. Not even in private repos — repos get leaked, employees leave, access is hard to revoke.
Use .gitignore for .env files:
# .gitignore
.env
.env.local
*.key
*.pem
config/secrets.yml
Add git-secrets or detect-secrets to your CI pipeline to catch accidental secret commits before they reach the repo.
9. Rate Limiting as Security
Rate limiting was covered architecturally in the Distributed Systems book (Chapter 17). From a security perspective, it protects against:
- Brute force attacks: 1000 login attempts per minute
- Credential stuffing: Trying leaked username/password pairs
- Enumeration:
GET /users/1throughGET /users/1000000 - DDoS: Overwhelming your origin with requests
- Card testing: Running stolen cards through checkout
Key security-specific limits:
POST /auth/login: 5 requests per IP per minute (strict)
POST /auth/password-reset: 3 requests per email per hour
GET /users/{id}: 100 requests per user per minute
POST /payments: 10 requests per user per minute
Return 429 Too Many Requests with Retry-After (Chapter 6). For brute-force specifically: exponential backoff — after 5 failures, require 1s wait; after 10, 10s; after 20, lockout.
10. How Real Systems Handle Security
Stripe:
- TLS 1.2 minimum, 1.3 recommended
- All API keys hashed in storage (SHA-256)
- Restricted API keys with per-resource scope
- Webhook signatures:
HMAC-SHA256(payload, webhook_secret)inStripe-Signatureheader - Idempotency keys prevent replay attacks on POST requests
GitHub:
- Secret scanning: scans all public repos for accidentally committed secrets
- Personal Access Tokens have fine-grained scopes + expiry
- SAML SSO enforcement for enterprise organizations
- IP allowlisting for enterprise API access
Twilio:
- Webhook request validation: verify
X-Twilio-Signatureheaderpythonimport hmac, hashlib, base64 expected = base64.b64encode( hmac.new(auth_token.encode(), (url + sorted_params).encode(), hashlib.sha1).digest() ).decode() assert request.headers["X-Twilio-Signature"] == expected - Account SID + Auth Token in Basic Auth (never in URL)
AWS:
- SigV4 request signing: entire request (method, URL, headers, body hash) signed with HMAC-SHA256
- 5-minute timestamp validation: prevents replay attacks
- IAM policies: fine-grained resource-level authorization
- VPC endpoints: API calls never leave AWS network
11. Vocabulary Map
| Term | Definition |
|---|---|
| OWASP | Open Web Application Security Project. Maintains the API Security Top 10. |
| BOLA | Broken Object Level Authorization. Accessing resources you don't own via their ID. Also called IDOR. |
| Mass assignment | Vulnerability where API applies all request body fields to a model, including privileged fields. |
| SSRF | Server-Side Request Forgery. Attacker makes your server fetch internal URLs. |
| SQL injection | Inserting SQL code into queries via user input. Prevented by parameterized queries. |
| NoSQL injection | Similar to SQL injection but with MongoDB-style operators ($gt, $where). |
| CORS | Cross-Origin Resource Sharing. Browser mechanism allowing cross-domain API calls with server permission. |
| Preflight | OPTIONS request browser sends automatically before non-simple cross-origin requests. |
| Same-Origin Policy | Browser rule: JavaScript can only fetch from its own origin (scheme+host+port). |
| HSTS | HTTP Strict Transport Security. Browser instruction to always use HTTPS for a domain. |
| CSRF | Cross-Site Request Forgery. Malicious site makes authenticated requests using victim's cookies. |
| Content-Type sniffing | Browser guessing content type ignoring the header. Prevented by X-Content-Type-Options: nosniff. |
| Clickjacking | Embedding your page in an iframe on a malicious site. Prevented by X-Frame-Options: DENY. |
| Input validation | Checking all incoming data for type, length, format, and range before processing. |
| Parameterized query | SQL query with placeholders for user input — prevents SQL injection. |
| Secrets management | Storing API keys, DB passwords, and tokens outside of source code. |
| HMAC | Hash-based Message Authentication Code. Used by Stripe/Twilio to sign webhooks. |
| Credential stuffing | Using leaked username/password pairs to try logging into accounts. Mitigated by rate limiting. |
| Brute force | Systematically trying all possible passwords. Mitigated by rate limiting + lockouts. |
| SigV4 | AWS's request signing algorithm. Signs the entire request to prevent tampering and replay attacks. |
| Algorithm confusion | JWT attack where RS256 endpoint accepts HS256 token signed with the public key. |
12. Practice Problems
Problem 1 — Failure Analysis:
Your API has GET /invoices/{invoice_id} that returns invoice details. Your authentication middleware correctly validates JWT tokens. User A (user_id: 42) calls GET /invoices/99. Invoice 99 belongs to User B (user_id: 77). Your handler: return db.get(Invoice, invoice_id). What vulnerability is this? What does the attacker do to find valid invoice IDs? Write the fixed handler. What status code do you return when user A tries to access user B's invoice, and why is that choice a security decision (hint: Chapter 6)?
Problem 2 — Design From Scratch: Design the complete security posture for a healthcare API that stores patient records. Include: (a) authentication mechanism and token design, (b) authorization model (which roles can access which resources), (c) input validation for patient data (name, DOB, diagnosis codes), (d) which OWASP risks are especially critical for healthcare data and why, (e) what data must never appear in logs, (f) what headers every response must include. Reference HIPAA considerations where relevant.
Problem 3 — Synthesis:
Your fintech API has a POST /transfers endpoint. Design the complete defense against these three attack scenarios: (a) an attacker submits 10,000 transfer requests in 1 minute using a valid stolen API key, (b) an attacker sends {"amount": -500} to transfer money into their account, (c) an attacker sends {"to_account_id": 1, "to_account_id": 2} (duplicate key) hoping one parser picks the first and another picks the second (HTTP parameter pollution). For each: what is the attack, what breaks if undefended, and what is the exact fix.
Problem 4 — Go Deeper:
You're building a webhook delivery system — your API makes POST requests to user-provided URLs when events happen. Design the complete SSRF protection: (a) URL validation before storing, (b) DNS rebinding attack — what is it and why does validating the URL at storage time not fully protect you, (c) how to resolve DNS at request time and validate the IP, (d) what happens if the webhook URL is https://attacker.com/redirect and the server responds with a 301 to http://169.254.169.254? What additional protection handles this?
Problem 5 — Connect to Previous:
From Chapter 5, you know JWTs contain user claims (role, user_id) and are signed with RS256. From Chapter 3, you know DELETE /users/{id} requires the user to own the resource or be an admin. From Chapter 6, you know to return 404 (not 403) to prevent resource existence revelation. From this chapter, you know about BOLA and function-level authorization. Write the complete DELETE /users/{id} handler (pseudocode) that correctly handles: unauthenticated request, expired token, authenticated non-owner non-admin, authenticated admin, authenticated owner deleting themselves, and resource not found. Show the exact status code and response body for each case.
What's Coming Next
Your API is secure. Now: how do you prove it works and explain it to others?
Chapter 10 — the final chapter — covers testing and documentation. Contract testing with OpenAPI, integration test strategies, mocking, load testing, and generating living documentation from your code. A well-tested, well-documented API is the difference between code and a product. This is how you ship with confidence.