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/login can comfortably fire 100 guessing attempts per minute without interruption.

High-Fidelity Tiering Strategy:

  1. Lightweight Read Routes (GET /items): Generous sliding-window limits (e.g. 120 req/min per IP) to ensure smooth user experiences.
  2. 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).
  3. 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.