Engineering in practice
Hardening API Security: Defense in Depth and Layered Rate Limiting
15 October 2026 · 8 min read
Security is Not a Single Checkpoint: Defense in Depth
Many software developers assume that placing a web application firewall (WAF) or an API Gateway in front of their backend and enforcing standard JSON Web Tokens (JWT) is sufficient to consider their application “secure”.
In reality, contemporary API breaches rarely attack network-layer ports. They exploit business logic vulnerabilities: credential stuffing against unthrottled endpoints, resource exhaustion via unconstrained query parameters, and accidental data exposure caused by leaky serialization models.
Real security requires Defense in Depth: concentric layers of independent security controls designed so that if any single safeguard fails or is bypassed, subsequent layers prevent compromise.
1. Tiered Rate Limiting: Matching Friction to Risk
Applying a single, blanket rate limit (e.g. 100 requests per minute per IP address) across an entire API surface is fundamentally flawed:
- Legitimate users browsing rich dashboards will hit the throttle unexpectedly during normal navigation.
- Malicious actors brute-forcing passwords or API keys on
/auth/logincan comfortably fire 100 guessing attempts per minute without interruption.
High-Fidelity Tiering Strategy:
- Lightweight Read Routes (
GET /items): Generous sliding-window limits (e.g. 120 req/min per IP) to ensure smooth user experiences. - Authentication & Reset Routes (
POST /auth/login,POST /auth/reset): Ultra-strict multi-key rate limiting (e.g. 5 failed attempts per 5 minutes per IP and per target username). - Expensive Computational Routes (
POST /reports/export): Tenant-level quota enforcement (e.g. 10 report generations per hour per authenticated tenant).
Enforce these checks using atomic Redis Lua scripts to eliminate race conditions across horizontally scaled web containers.
2. Eliminating Excessive Data Exposure via Strict Pydantic Models
Ranked prominently on the OWASP API Security Top 10 is Excessive Data Exposure: endpoints that serialize and return the raw relational database entity directly to the client:
# SEVERE SECURITY VULNERABILITY:
# Returns every database column, including password_hash, salt, and internal role flags!
@app.get("/users/{user_id}")
async def get_user(user_id: int):
user = await user_repo.get_by_id(user_id)
return user
The Strict Contract Rule: Never Serialize Without a Response Model
Always enforce an explicit response contract:
class UserPublicResponse(BaseModel):
id: int
username: str
display_name: str
created_at: datetime
class Config:
from_attributes = True
@app.get("/users/{user_id}", response_model=UserPublicResponse)
async def get_user(user_id: int):
user = await user_repo.get_by_id(user_id)
return user # Pydantic strictly strips all un-modeled internal fields
Even if a new confidential column is added to the database table in the future, the API contract strictly filters it out, guaranteeing zero inadvertent exposure.
3. Defense-in-Depth HTTP Security Headers
Every response emitted by your web framework should include hardened security headers that instruct client browsers to reject content-sniffing, framing attacks, and unencrypted connections:
@app.middleware("http")
async def apply_security_headers(request: Request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
response.headers["Strict-Transport-Security"] = "max-age=63072000; includeSubDomains; preload"
return response
Cross-Origin Resource Sharing (CORS) Hardening
Never deploy allow_origins=["*"] alongside allow_credentials=True. Configure strict, environment-driven origin allowlists:
app.add_middleware(
CORSMiddleware,
allow_origins=settings.ALLOWED_CORS_ORIGINS, # Pinned explicitly per deployment profile
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
allow_headers=["Authorization", "Content-Type", "X-Correlation-ID"],
)
Key Principles
- Rate limit endpoints according to their computational cost and attack sensitivity.
- Explicitly map database entities to dedicated outward-facing Pydantic schemas.
- Enforce security headers on all HTTP responses.
- Disallow wildcards in production CORS policies.