Latest Intelligence

12 API Concepts — The AppSec & DevSecOps Edition

7/25/2026
Research Report

12 API Concepts — The AppSec & DevSecOps Edition

Introduction

APIs are the backbone of modern applications, powering everything from mobile apps and payment systems to internal microservices and third-party integrations. But understanding how APIs work is only half the story. The other half is understanding how they can fail, how they can be abused, and how to design them securely from day one.

In this article, we’ll revisit 12 core API concepts through the lens of Application Security (AppSec) and DevSecOps. Instead of focusing only on the happy path, we’ll also explore the security risks behind each concept — including broken authorization, weak authentication, SSRF, replay attacks, GraphQL abuse, retry storms, and data leakage. The goal is simple: help you build APIs that are not only functional and scalable, but also resilient, secure, and production-ready.

Why This Post Exists

The original "12 API Concepts" post teaches you how APIs work. This companion post teaches you how APIs break — and how to prevent it. Every single one of those 12 concepts has a security surface. Most developers learn the happy path. Attackers study the unhappy path.

This post covers both.

We'll walk through each concept and ask three questions:

  1. What can go wrong? — the attack vectors
  2. What does it look like? — real exploit scenarios
  3. How do you fix it? — defensive patterns and tooling

At the end, we'll tie it all together with a DevSecOps pipeline that catches these issues before production.

Before We Start: The OWASP API Security Top 10 (2023)

OWASP maintains a list of the most critical API security risks. You'll see these referenced throughout:

#RiskOne-liner
API1Broken Object Level Authorization (BOLA)Accessing other users' data by changing IDs
API2Broken AuthenticationWeak login, token, or session mechanisms
API3Broken Object Property Level AuthorizationSeeing/changing fields you shouldn't
API4Unrestricted Resource ConsumptionNo rate limits, no pagination caps
API5Broken Function Level AuthorizationCalling admin endpoints as regular user
API6Unrestricted Access to Sensitive Business FlowsAutomating things meant for humans
API7Server Side Request Forgery (SSRF)Making the server call internal resources
API8Security MisconfigurationDebug mode on, CORS open, headers missing
API9Improper Inventory ManagementOld/shadow/deprecated APIs still accessible
API10Unsafe Consumption of APIsTrusting third-party API responses blindly

Keep this table handy. Almost every vulnerability we discuss maps to one of these.

  1. REST — Attack Surface by Design

What the Original Post Taught

REST = verbs (GET, POST, PUT, DELETE) acting on resources (URLs). Stateless. Clean.

What Can Go Wrong

Mass Assignment (API3)

When you accept a JSON body and blindly map it to your database model:

json
// Client sends:
POST /users
{
  "name": "Alice",
  "email": "alice@example.com",
  "role": "admin"        ← attacker adds this
}

If your server does User.create(req.body) without filtering, the attacker just made themselves admin.

Broken Object Level Authorization — BOLA (API1)

The #1 API vulnerability. Period.

text
GET /users/42/orders      ← Alice views her orders
GET /users/43/orders      ← Alice changes 42 to 43, sees Bob's orders

The server checked that Alice is authenticated but never checked whether Alice is authorized to see user 43's data.

HTTP Method Override Attacks

Some frameworks support X-HTTP-Method-Override header. An attacker might send:

text
POST /users/42
X-HTTP-Method-Override: DELETE

If your firewall only blocks DELETE requests but the framework honors the override header, the user gets deleted via a POST.

Verb Tampering

text
GET /admin/users       → 403 Forbidden
HEAD /admin/users      → 200 OK (oops, leaked data in headers)

Some servers implement authorization only for specific HTTP methods.

How to Fix It

python
# ❌ DANGEROUS — Mass Assignment
@app.post("/users")
def create_user(request):
    user = User(**request.json)  # accepts ANY field
    db.save(user)

# ✅ SAFE — Explicit whitelist with schema validation
from pydantic import BaseModel

class CreateUserRequest(BaseModel):
    name: str
    email: str
    # role is NOT here — can't be set by client

@app.post("/users")
def create_user(payload: CreateUserRequest):
    user = User(
        name=payload.name,
        email=payload.email,
        role="user"  # always default
    )
    db.save(user)
python
# ❌ BOLA vulnerability
@app.get("/users/{user_id}/orders")
def get_orders(user_id: int, token: JWTToken):
    return db.get_orders(user_id)  # no ownership check!

# ✅ BOLA fixed
@app.get("/users/{user_id}/orders")
def get_orders(user_id: int, token: JWTToken):
    if token.user_id != user_id and token.role != "admin":
        raise HTTPException(403, "Access denied")
    return db.get_orders(user_id)

Security Checklist for REST

  • [ ] Input validation on every endpoint (schema + type + length + range)
  • [ ] Whitelist fields — never blindly accept request bodies
  • [ ] Object-level authorization — check ownership on every resource access
  • [ ] Function-level authorization — admin endpoints need admin checks
  • [ ] Disable HTTP method overrides unless explicitly needed
  • [ ] Reject unexpected content types — only accept application/json if that's what you expect
  • [ ] Use UUIDs instead of sequential IDs — makes BOLA enumeration harder (not impossible, but harder)
  1. Idempotency — Race Conditions and Double-Spend

What the Original Post Taught

Send an Idempotency-Key header so retries don't create duplicates.

What Can Go Wrong

TOCTOU Race Condition (Time-of-Check-to-Time-of-Use)

The "check if key exists" and "store the key" happen in separate steps. If two requests arrive in the gap between check and store, both pass.

Fix: Use atomic operations or database-level locking

sql
-- ✅ Atomic upsert — only one wins
INSERT INTO idempotency_keys (key, result)
VALUES ('k1', NULL)
ON CONFLICT (key) DO NOTHING
RETURNING *;
-- If this returns nothing, the key already existed → return cached result

Or in application code with distributed locking:

python
async def process_with_idempotency(key, func):
    # Atomic lock acquisition
    lock = await redis.set(f"idem:{key}", "locked", nx=True, ex=60)
    if not lock:
        # Key exists — return cached result
        return await redis.get(f"idem_result:{key}")
    
    try:
        result = await func()
        await redis.set(f"idem_result:{key}", result, ex=86400)
        return result
    except Exception:
        await redis.delete(f"idem:{key}")  # allow retry on failure
        raise

Idempotency Key Manipulation

What if an attacker sends someone else's idempotency key?

text
"text-slate-500 font-normal italic"># Attacker discovers key format is predictable
POST /payments (Key: user42-order-001)   → sees cached result of another user's payment

Fix: Scope idempotency keys to the authenticated user. Key k1 for user 42 is different from key k1 for user 43. Internally store it as user:42:k1.

Security Checklist for Idempotency

  • [ ] Atomic key checking — use DB constraints or distributed locks, never check-then-insert
  • [ ] Scope keys to users — one user's key can't collide with another's
  • [ ] Expire keys — don't keep them forever (24-48 hours is typical)
  • [ ] Don't leak cached results — the key's cached response should only be returned to the same user
  1. Pagination — Data Leakage and Denial of Service

What the Original Post Taught

Break big lists into pages. Use cursor or offset.

What Can Go Wrong

Denial of Service via Unlimited Page Size (API4)

text
GET /users?limit=999999999

If the server doesn't cap limit, the attacker forces a query that returns millions of records, exhausting memory, CPU, and database connections.

Data Leakage Through Offset Enumeration

text
GET /users?limit=1&offset=0    → user 1
GET /users?limit=1&offset=1    → user 2
GET /users?limit=1&offset=2    → user 3
... repeat 10 million times     → entire user database scraped

Cursor Tampering

If your cursor is a base64-encoded {"id": 42, "created_at": "..."}, an attacker can decode it, modify the values, and access data they shouldn't see.

How to Fix It

python
MAX_PAGE_SIZE = 100
DEFAULT_PAGE_SIZE = 20

@app.get("/users")
def list_users(limit: int = DEFAULT_PAGE_SIZE, cursor: str = None):
    # ✅ Cap the page size
    limit = min(limit, MAX_PAGE_SIZE)
    
    if limit < 1:
        raise HTTPException(400, "limit must be >= 1")
    
    # ✅ Validate and decrypt cursor (don't use plain base64!)
    if cursor:
        cursor_data = decrypt_and_verify_cursor(cursor)  # HMAC-signed
    
    # ✅ Only return fields the user is allowed to see
    users = db.query(User).filter_authorized(current_user)
    
    return paginate(users, limit, cursor_data)
python
import hmac, hashlib, json, base64

SECRET = "your-cursor-signing-secret"

def create_cursor(data: dict) -> str:
    """Create a tamper-proof cursor"""
    payload = json.dumps(data).encode()
    signature = hmac.new(SECRET.encode(), payload, hashlib.sha256).hexdigest()
    token = base64.urlsafe_b64encode(
        json.dumps({"payload": data, "sig": signature}).encode()
    )
    return token.decode()

def verify_cursor(cursor: str) -> dict:
    """Verify cursor hasn't been tampered with"""
    decoded = json.loads(base64.urlsafe_b64decode(cursor))
    payload = json.dumps(decoded["payload"]).encode()
    expected_sig = hmac.new(SECRET.encode(), payload, hashlib.sha256).hexdigest()
    
    if not hmac.compare_digest(expected_sig, decoded["sig"]):
        raise HTTPException(400, "Invalid cursor")
    
    return decoded["payload"]

Security Checklist for Pagination

  • [ ] Enforce maximum page size (50-100 is common)
  • [ ] Sign cursors with HMAC — prevent tampering
  • [ ] Apply authorization to paginated results (don't return other users' data)
  • [ ] Rate limit pagination endpoints to prevent scraping
  • [ ] Don't expose total count if it's sensitive (e.g., "we have 3 users in North Korea")
  • [ ] Monitor for sequential scraping patterns — alert on offset=0,1,2,3,...
  1. Rate Limiting — Your First Line of Defense

What the Original Post Taught

Cap requests per time window. Return 429 when exceeded.

What Can Go Wrong (When Rate Limiting Is Missing or Weak)

Brute Force Attacks (API2)

text
POST /login {email: "admin@co.com", password: "password1"}    → 401
POST /login {email: "admin@co.com", password: "password2"}    → 401
POST /login {email: "admin@co.com", password: "password3"}    → 401
... 10,000 attempts per second ...
POST /login {email: "admin@co.com", password: "dragon123"}    → 200 💀

Without rate limiting, an attacker can try millions of passwords.

Credential Stuffing

Attackers take email/password pairs from previous data breaches and try them on your API. They might have 100 million credentials to test.

Enumeration Attacks (API6)

text
GET /users/check?email=alice@example.com    → 200 (exists)
GET /users/check?email=bob@example.com      → 404 (doesn't exist)
GET /users/check?email=carol@example.com    → 200 (exists)

Without rate limiting, an attacker can enumerate all registered users.

Distributed Attacks (Bypassing Per-IP Rate Limits)

Per-IP rate limiting alone is insufficient — attackers use proxy networks.

Defense in Depth

Different endpoints need different limits:

EndpointLimitWhy
POST /login5/minute per IP+emailBrute force prevention
POST /forgot-password3/hour per emailAbuse prevention
GET /products100/minute per userNormal usage
POST /payments10/minute per userFinancial operations
GET /search30/minute per IPAnti-scraping

Sensitive Endpoint Hardening

For login endpoints specifically:

python
# ✅ Multi-factor rate limiting for login
@rate_limit(per_ip="10/minute")
@rate_limit(per_email="5/minute")       # prevents brute force on one account
@rate_limit(per_ip_failed="3/minute")   # stricter after failures
@app.post("/login")
def login(email: str, password: str):
    user = authenticate(email, password)
    if not user:
        # ✅ CRITICAL: Same response time whether user exists or not
        # Prevents timing-based enumeration
        constant_time_delay()
        raise HTTPException(401, "Invalid credentials")
        # ❌ NEVER say "user not found" vs "wrong password"
    
    return {"token": create_jwt(user)}

Security Checklist for Rate Limiting

  • [ ] Rate limit everything — especially auth endpoints
  • [ ] Layer your limits — global → per-IP → per-user → per-endpoint
  • [ ] Stricter limits on sensitive endpoints — login, password reset, payments
  • [ ] Don't just limit by IP — also by user ID, API key, email
  • [ ] Account lockout — lock after N failed attempts (but beware DoS via locking others' accounts)
  • [ ] CAPTCHA after threshold — add challenge after suspicious patterns
  • [ ] Monitor and alert — sustained rate limit hits should trigger investigation
  1. Versioning — Deprecated APIs as Attack Surface

What the Original Post Taught

Maintain /v1 and /v2 simultaneously so old clients keep working.

What Can Go Wrong

Forgotten Old Versions (API9 — Improper Inventory Management)

This is one of the most overlooked attack vectors in all of API security.

You patched the SQL injection in /v2. But /v1 still has it. And /v1 is still running. Attackers know to try old versions.

Security Bypass via Version Downgrade

text
"text-slate-500 font-normal italic"># v2 requires MFA for deleting accounts
DELETE /v2/users/42    → requires MFA token → attacker can't delete

# v1 doesn't have MFA...
DELETE /v1/users/42    → no MFA check → account deleted 💀

Shadow APIs

APIs that exist but aren't in your documentation or inventory:

  • /internal/debug
  • /api/v0/test
  • /admin/graphql
  • api-staging.example.com (accessible from the internet)

Attackers use tools like ffuf, dirsearch, and kiterunner to find these.

How to Fix It

python
# ✅ Deprecation middleware — force security on old versions too
@app.middleware("http")
async def version_security(request, call_next):
    path = request.url.path
    
    if path.startswith("/v1"):
        # Option 1: Block it entirely
        return JSONResponse(
            status_code=410,  # 410 Gone
            content={
                "error": "API v1 is sunset",
                "migration_guide": "https://docs.example.com/migrate-v2"
            },
            headers={"Sunset": "Sat, 01 Jan 2024 00:00:00 GMT"}
        )
    
    # Option 2: If you must keep it alive, apply same security as v2
    if path.startswith("/v1"):
        await verify_auth(request)      # same auth as v2
        await check_rate_limit(request)  # same rate limits
    
    return await call_next(request)

API Inventory Discipline

yaml
# api-inventory.yaml — EVERY API surface documented
apis:
  - path: /v2/*
    status: active
    auth: required
    rate_limited: true
    last_security_review: 2024-06-01

  - path: /v1/*
    status: deprecated
    sunset_date: 2024-12-31
    auth: required
    rate_limited: true
    redirect_to: /v2

  - path: /internal/*
    status: internal
    network: vpc-only    # NOT internet accessible
    auth: mTLS

Security Checklist for Versioning

  • [ ] Maintain an API inventory — every version, every endpoint, every environment
  • [ ] Apply same security controls to all live versions (auth, rate limit, validation)
  • [ ] Actually shut down deprecated versions — don't just "deprecate" them in docs
  • [ ] Scan for shadow APIs regularly with automated tools
  • [ ] Environment isolation — staging/internal APIs must not be internet-accessible
  • [ ] Sunset headers — Sunset: <date> header on deprecated endpoints
  1. Webhooks — SSRF, Replay, and Trust

What the Original Post Taught

The server calls your URL when an event happens. Verify signatures.

What Can Go Wrong

SSRF — Server-Side Request Forgery (API7)

This is the big one. When you let users provide a URL that your server will call, you're giving them the ability to make your server send requests anywhere — including to your internal network.

169.254.169.254 is the AWS metadata endpoint — accessible only from inside the VPC. By registering it as a webhook URL, the attacker makes your server fetch your cloud credentials.

Replay Attacks

An attacker intercepts a legitimate webhook payload and sends it again. If the server processes it without checking, the attacker can trigger duplicate refunds, fake event confirmations, etc.

text
"text-slate-500 font-normal italic"># Attacker captures this webhook:
POST /hooks
X-Signature: sha256=abc123
{"event": "payment.refund", "amount": 5000}

"text-slate-500 font-normal italic"># Attacker replays it 100 times → 100 refunds

Signature Bypass

python
# ❌ Vulnerable — timing attack on string comparison
if signature == expected_signature:
    process_webhook()

# An attacker can guess the signature character by character
# by measuring response time differences

How to Fix It

SSRF Prevention:

python
from urllib.parse import urlparse
import ipaddress
import socket

BLOCKED_RANGES = [
    ipaddress.ip_network("10.0.0.0/8"),         # Private
    ipaddress.ip_network("172.16.0.0/12"),       # Private
    ipaddress.ip_network("192.168.0.0/16"),      # Private
    ipaddress.ip_network("169.254.0.0/16"),      # Link-local / AWS metadata
    ipaddress.ip_network("127.0.0.0/8"),         # Loopback
    ipaddress.ip_network("0.0.0.0/8"),           # Special
    ipaddress.ip_network("::1/128"),             # IPv6 loopback
]

def validate_webhook_url(url: str) -> bool:
    parsed = urlparse(url)
    
    # ✅ Only allow HTTPS
    if parsed.scheme != "https":
        return False
    
    # ✅ Resolve hostname to IP and check against blocked ranges
    try:
        ip = ipaddress.ip_address(socket.gethostbyname(parsed.hostname))
    except (socket.gaierror, ValueError):
        return False
    
    for blocked in BLOCKED_RANGES:
        if ip in blocked:
            return False  # ❌ Internal IP — SSRF attempt
    
    # ✅ Reject unusual ports
    if parsed.port and parsed.port not in (443,):
        return False
    
    return True

Replay Prevention:

python
import time

def verify_webhook(payload, headers):
    timestamp = int(headers["X-Webhook-Timestamp"])
    signature = headers["X-Webhook-Signature"]
    
    # ✅ Reject old webhooks (>5 minutes = replay)
    if abs(time.time() - timestamp) > 300:
        raise HTTPException(401, "Webhook too old — possible replay")
    
    # ✅ Include timestamp in signature verification
    signed_content = f"{timestamp}.{payload.decode()}"
    expected = hmac.new(
        WEBHOOK_SECRET.encode(),
        signed_content.encode(),
        hashlib.sha256
    ).hexdigest()
    
    # ✅ Constant-time comparison — prevents timing attacks
    if not hmac.compare_digest(f"sha256={expected}", signature):
        raise HTTPException(401, "Invalid signature")
    
    # ✅ Idempotency — check if we've already processed this event
    event_id = json.loads(payload)["id"]
    if redis.exists(f"webhook_processed:{event_id}"):
        return {"status": "already processed"}
    
    redis.set(f"webhook_processed:{event_id}", 1, ex=86400)
    process_event(payload)

Security Checklist for Webhooks

  • [ ] Validate URLs against internal IP ranges — prevent SSRF
  • [ ] HTTPS only — never send webhooks over HTTP
  • [ ] Verify signatures with constant-time comparison (hmac.compare_digest)
  • [ ] Include timestamp in signature — check for freshness (< 5 minutes)
  • [ ] Deduplicate by event ID — prevent replay attacks
  • [ ] DNS rebinding protection — resolve hostname and check IP at request time, not just registration time
  • [ ] Outbound firewall — webhook sender should not be able to reach internal services
  1. gRPC — Binary Doesn't Mean Safe

What the Original Post Taught

Binary RPC over HTTP/2 with protobuf. Fast, typed, streamable.

What Can Go Wrong

"Security Through Obscurity" Fallacy

People think: "gRPC uses binary protobuf, not human-readable JSON, so it's harder to attack." Wrong. Tools like grpcurl, grpcui, and protobuf-decoder can decode and craft gRPC messages just as easily as curl crafts REST requests.

bash
"text-slate-500 font-normal italic"># Attacker inspecting your gRPC service
grpcurl -plaintext target:50051 list
"text-slate-500 font-normal italic"># Returns all available services!

grpcurl -plaintext target:50051 describe myapp.UserService
"text-slate-500 font-normal italic"># Returns all methods and message types!

Reflection API Exposure

gRPC has a reflection API that lets clients discover all available services, methods, and message types at runtime. It's meant for development tools. If it's enabled in production, attackers get a full map of your API.

Protobuf Deserialization Attacks

Malformed protobuf messages can cause:

  • Memory exhaustion (deeply nested messages)
  • Integer overflow
  • Buffer over-reads in poorly implemented parsers

No TLS = Everything in Plaintext

gRPC over HTTP/2 without TLS means all your "binary" traffic is readable by anyone on the network.

How to Fix It

python
# ✅ gRPC server with security hardening
import grpc
from concurrent import futures

def create_secure_server():
    server = grpc.server(
        futures.ThreadPoolExecutor(max_workers=10),
        # ✅ Limit message sizes to prevent memory exhaustion
        options=[
            ('grpc.max_receive_message_length', 4 * 1024 * 1024),  # 4MB max
            ('grpc.max_send_message_length', 4 * 1024 * 1024),
        ]
    )
    
    # ✅ TLS — always in production
    with open('server.key', 'rb') as f:
        private_key = f.read()
    with open('server.crt', 'rb') as f:
        certificate = f.read()
    
    credentials = grpc.ssl_server_credentials([(private_key, certificate)])
    server.add_secure_port('[::]:50051', credentials)
    
    # ✅ Do NOT add reflection service in production
    # reflection.enable_server_reflection(SERVICE_NAMES, server)  ← REMOVE THIS
    
    return server
python
# ✅ gRPC interceptor for auth + rate limiting
class AuthInterceptor(grpc.ServerInterceptor):
    def intercept_service(self, continuation, handler_call_details):
        metadata = dict(handler_call_details.invocation_metadata)
        
        token = metadata.get('authorization', '')
        if not token.startswith('Bearer '):
            return self._unauthenticated()
        
        try:
            claims = verify_jwt(token[7:])
        except InvalidTokenError:
            return self._unauthenticated()
        
        # ✅ Check authorization for specific methods
        method = handler_call_details.method
        if method == '/myapp.UserService/DeleteUser':
            if claims.get('role') != 'admin':
                return self._permission_denied()
        
        return continuation(handler_call_details)

Security Checklist for gRPC

  • [ ] Always use TLS — grpc.ssl_server_credentials(), never plaintext in production
  • [ ] Disable reflection in production environments
  • [ ] Implement auth interceptors — check JWT/mTLS on every call
  • [ ] Set message size limits — prevent memory exhaustion
  • [ ] Use mTLS for service-to-service — both sides verify certificates
  • [ ] Apply the same BOLA checks as REST — gRPC is not magically exempt
  • [ ] Input validation on protobuf fields — types are enforced but not ranges/lengths
  1. GraphQL — A Hacker's Playground

What the Original Post Taught

One endpoint, ask for exactly what you need. No over-fetching.

What Can Go Wrong

GraphQL is incredibly powerful — and that power is also its biggest security liability. When you give clients the ability to construct arbitrary queries, you give attackers enormous flexibility.

Introspection Data Leakage

GraphQL has a built-in introspection system. Attackers can query it to discover your entire schema:

graphql
{
  __schema {
    types {
      name
      fields {
        name
        type { name }
      }
    }
  }
}

This returns every type, every field, every relationship in your API. Including ones you didn't document. Including adminMutations, internalDebugQuery, deleteDatabaseMutation.

Query Depth Attacks (API4)

graphql
{
  user(id: 1) {
    friends {
      friends {
        friends {
          friends {
            friends {
              friends {
                "text-slate-500 font-normal italic"># 50 levels deep
                "text-slate-500 font-normal italic"># Each level multiplies database queries
                "text-slate-500 font-normal italic"># Server runs millions of DB queries → crash
              }
            }
          }
        }
      }
    }
  }
}

Batching Attacks

GraphQL allows sending multiple operations in one request. Attackers use this for:

graphql
"text-slate-500 font-normal italic"># Brute force login — 1000 attempts in one HTTP request, bypassing rate limits
mutation {
  login1: login(email: "admin@co.com", password: "password1") { token }
  login2: login(email: "admin@co.com", password: "password2") { token }
  login3: login(email: "admin@co.com", password: "password3") { token }
  "text-slate-500 font-normal italic"># ... 997 more ...
}

Your rate limiter sees 1 HTTP request. The server executes 1000 login attempts.

Field-Level Authorization Failure (API3)

graphql
"text-slate-500 font-normal italic"># Normal user query — returns their own salary
{ me { name salary } }

"text-slate-500 font-normal italic"># Same user queries someone else — and sees THEIR salary too
{ user(id: 42) { name salary ssn } }

How to Fix It

python
# ✅ GraphQL security configuration

# 1. Disable introspection in production
schema = build_schema(
    introspection=False  # ← This is critical in production
)

# 2. Query depth limiting
from graphql import validate
from graphql_depth_limit import DepthLimitValidator

MAX_DEPTH = 5
MAX_COMPLEXITY = 1000

# 3. Query complexity analysis
def calculate_complexity(query):
    """Each field = 1 point, each list field = 10 points"""
    complexity = 0
    for field in query.fields:
        if field.is_list:
            complexity += 10 * calculate_complexity(field)
        else:
            complexity += 1 + calculate_complexity(field)
    return complexity

# 4. Combined validation
@app.post("/graphql")
def graphql_handler(request):
    query = request.json["query"]
    
    # ✅ Check query depth
    depth = calculate_depth(query)
    if depth > MAX_DEPTH:
        return {"error": f"Query depth {depth} exceeds maximum {MAX_DEPTH}"}
    
    # ✅ Check query complexity
    complexity = calculate_complexity(query)
    if complexity > MAX_COMPLEXITY:
        return {"error": f"Query complexity {complexity} exceeds maximum"}
    
    # ✅ Check batch size (aliases)
    alias_count = count_aliases(query)
    if alias_count > 10:
        return {"error": "Too many aliases in one query"}
    
    return execute(schema, query)

Field-Level Authorization:

python
# ✅ Resolver-level authorization
@resolve_field("User", "salary")
def resolve_salary(user, info):
    current_user = info.context["user"]
    
    # Only the user themselves or HR can see salary
    if current_user.id != user.id and "hr" not in current_user.roles:
        return None  # or raise PermissionError
    
    return user.salary

@resolve_field("User", "ssn")
def resolve_ssn(user, info):
    current_user = info.context["user"]
    if current_user.id != user.id:
        raise PermissionError("Cannot access other users' SSN")
    return user.ssn

Security Checklist for GraphQL

  • [ ] Disable introspection in production — always
  • [ ] Limit query depth (max 5-10 levels)
  • [ ] Limit query complexity (assign cost to each field)
  • [ ] Limit batch size (aliases per query)
  • [ ] Field-level authorization in every resolver
  • [ ] Rate limit by query complexity, not just HTTP requests
  • [ ] Persisted queries — only allow pre-approved queries in production (best defense)
  • [ ] Disable suggestions — don't tell attackers "Did you mean adminMutation?"
  1. Auth — The Crown Jewels

What the Original Post Taught

JWT tokens, OAuth 2.0, 401 vs 403, scopes.

What Can Go Wrong — JWT Attacks

Algorithm Confusion Attack (alg:none)

JWTs have a header that specifies the signing algorithm. Some libraries trust this header blindly:

json
// Attacker crafts a JWT with alg: "none"
{
  "alg": "none",    ← tells server "no signature needed"
  "typ": "JWT"
}
.
{
  "sub": "42",
  "role": "admin"   ← escalated privileges
}
.
                     ← empty signature

If the server's JWT library honors alg: none, the attacker can forge any token they want.

RS256 → HS256 Key Confusion

If the server uses RS256 (asymmetric — public/private key pair), an attacker can:

  1. Download the public key (it's public!)
  2. Forge a JWT signed with HS256 using the public key as the HMAC secret
  3. Some JWT libraries will verify an HS256 token using the RS256 public key as the HMAC key — and it passes!

Token Storage — XSS + Token Theft

Storage LocationXSS Vulnerable?CSRF Vulnerable?Recommendation
localStorage✅ Yes — JS can read it❌ No❌ Avoid
sessionStorage✅ Yes — JS can read it❌ No❌ Avoid
HttpOnly Cookie❌ No — JS can't access✅ Yes (needs CSRF token)✅ Best for web
Memory (variable)✅ Yes (if XSS exists)❌ No⚠️ Lost on refresh

Broken Refresh Token Flow

How to Fix It

python
import jwt
from datetime import datetime, timedelta

# ✅ Secure JWT verification
ALLOWED_ALGORITHMS = ["RS256"]  # NEVER allow "none" or HS256

def verify_token(token: str) -> dict:
    try:
        payload = jwt.decode(
            token,
            PUBLIC_KEY,
            algorithms=ALLOWED_ALGORITHMS,  # ✅ Explicit algorithm whitelist
            options={
                "require": ["exp", "sub", "iss"],  # ✅ Require critical claims
                "verify_exp": True,
                "verify_iss": True,
            },
            issuer="https://auth.yourdomain.com"  # ✅ Verify issuer
        )
        return payload
    except jwt.ExpiredSignatureError:
        raise HTTPException(401, "Token expired")
    except jwt.InvalidTokenError:
        raise HTTPException(401, "Invalid token")


# ✅ Secure token creation
def create_tokens(user_id: str, role: str):
    now = datetime.utcnow()
    
    access_token = jwt.encode({
        "sub": user_id,
        "role": role,
        "iss": "https://auth.yourdomain.com",
        "aud": "https://api.yourdomain.com",
        "exp": now + timedelta(minutes=15),    # ✅ Short-lived
        "iat": now,
        "jti": generate_unique_id()            # ✅ Unique ID for revocation
    }, PRIVATE_KEY, algorithm="RS256")
    
    refresh_token = create_opaque_token()       # ✅ NOT a JWT — stored in DB
    store_refresh_token(refresh_token, user_id, expires=timedelta(days=30))
    
    return access_token, refresh_token
python
# ✅ Refresh token rotation — detect theft
def refresh_access_token(refresh_token: str):
    token_record = db.get_refresh_token(refresh_token)
    
    if not token_record:
        raise HTTPException(401, "Invalid refresh token")
    
    if token_record.is_used:
        # ✅ This token was already used! Someone stole it.
        # Revoke ALL tokens for this user
        db.revoke_all_tokens(token_record.user_id)
        alert_security_team(token_record.user_id, "Refresh token reuse detected")
        raise HTTPException(401, "Token reuse detected — all sessions revoked")
    
    # ✅ Mark current token as used
    db.mark_token_used(refresh_token)
    
    # ✅ Issue new pair
    new_access, new_refresh = create_tokens(token_record.user_id, token_record.role)
    return new_access, new_refresh

Secure Cookie Configuration

python
response.set_cookie(
    key="access_token",
    value=token,
    httponly=True,      # ✅ JavaScript cannot access
    secure=True,        # ✅ Only sent over HTTPS
    samesite="Strict",  # ✅ Not sent in cross-site requests (CSRF protection)
    max_age=900,        # ✅ 15 minutes
    domain=".yourdomain.com",
    path="/api"
)

Security Checklist for Auth

  • [ ] Whitelist JWT algorithms — never allow none, never allow algorithm switching
  • [ ] Short-lived access tokens (5-15 minutes)
  • [ ] Opaque refresh tokens stored in DB (not JWT) — allows revocation
  • [ ] Refresh token rotation — new refresh token with each refresh, detect reuse
  • [ ] HttpOnly + Secure + SameSite cookies for web apps
  • [ ] Token revocation list (JTI blacklist) for compromised tokens
  • [ ] Password hashing with bcrypt/argon2 (cost factor ≥ 12)
  • [ ] MFA on sensitive operations — not just login
  • [ ] Constant-time credential comparison — no timing attacks
  1. Retries — Amplification and Retry Storms

What the Original Post Taught

Exponential backoff + jitter on 5xx errors.

What Can Go Wrong

Retry Storms (Cascading Failure Amplification)

3 layers × 3 retries each = 27x amplification from a single failed request. With 1000 concurrent users, that's 27,000 database queries from what should have been 1,000.

Retries as DDoS Amplification

If an attacker can trigger errors (e.g., by sending requests that cause 500s), and your system retries those errors, the attacker's traffic is amplified:

text
Attacker sends: 100 requests
Your retries: 100 × 3 retries = 300 internal requests
Service B retries: 300 × 3 = 900 requests to Service C
Total: 1,300 requests from 100 attack requests

How to Fix It

python
# ✅ Secure retry strategy with circuit breaker

class SecureRetryClient:
    def __init__(self):
        self.max_retries = 3
        self.circuit_breaker = CircuitBreaker(
            failure_threshold=5,     # Open after 5 failures
            recovery_timeout=30,     # Try again after 30s
        )
        self.retry_budget = RetryBudget(
            max_retry_ratio=0.1     # ✅ Max 10% of requests can be retries
        )
    
    async def call(self, func):
        # ✅ Circuit breaker — stop calling broken services
        if self.circuit_breaker.is_open:
            raise ServiceUnavailable("Circuit open — skipping call")
        
        for attempt in range(self.max_retries):
            # ✅ Retry budget — prevent retry storms
            if attempt > 0 and not self.retry_budget.allow_retry():
                raise ServiceUnavailable("Retry budget exhausted")
            
            try:
                result = await func()
                self.circuit_breaker.record_success()
                return result
            except RetryableError as e:
                self.circuit_breaker.record_failure()
                
                if attempt < self.max_retries - 1:
                    wait = (2 ** attempt) + random.uniform(0, 1)
                    # ✅ Cap maximum wait time
                    wait = min(wait, 30)
                    await asyncio.sleep(wait)
        
        raise MaxRetriesExceeded()

Security Checklist for Retries

  • [ ] Retry budgets — limit total retry percentage (10% max)
  • [ ] Circuit breakers — stop retrying when a service is clearly down
  • [ ] Cap retry count at each layer — never > 3
  • [ ] End-to-end retry limit — pass "remaining retries" in headers
  • [ ] Never retry 4xx (except 429) — these aren't transient
  • [ ] Log retry patterns — sudden spike in retries = something is wrong
  • [ ] Max backoff cap — don't let wait times grow unbounded
  1. Timeouts — Resource Exhaustion and Slowloris

What the Original Post Taught

Set connect timeout and read timeout. Fail fast.

What Can Go Wrong

Slowloris Attack

The attacker opens hundreds of connections and sends headers extremely slowly — one byte per second. Each connection ties up a server thread. The server eventually runs out of threads and can't accept legitimate requests.

ReDoS — Regular Expression Denial of Service

If your input validation uses regex and you don't set timeouts on regex evaluation:

python
# ❌ Vulnerable regex
email_pattern = r"^([a-zA-Z0-9]+\.)*[a-zA-Z0-9]+@([a-zA-Z0-9]+\.)*[a-zA-Z0-9]+$"

# Attacker sends:
evil_input = "a" * 50 + "@" + "a" * 50

# This regex takes exponential time to evaluate
# Server thread hangs for minutes or hours

Thread Pool Exhaustion Without Timeouts

text
Service A calls Service B (no timeout set)
Service B is frozen
Service A's thread waits... forever
All of Service A's threads get stuck waiting for B
Service A becomes completely unresponsive
Now Service C, which depends on A, also gets stuck
Cascading failure across entire system 💀

How to Fix It

python
# ✅ Comprehensive timeout configuration
import httpx
import asyncio

# HTTP client with proper timeouts
client = httpx.AsyncClient(
    timeout=httpx.Timeout(
        connect=2.0,     # ✅ Max time to establish TCP connection
        read=10.0,       # ✅ Max time to receive response
        write=5.0,       # ✅ Max time to send request body
        pool=5.0,        # ✅ Max time to acquire a connection from pool
    ),
    limits=httpx.Limits(
        max_connections=100,           # ✅ Total connection pool
        max_keepalive_connections=20,  # ✅ Persistent connections
    )
)

# ✅ Overall operation deadline
async def fetch_with_deadline(url: str):
    try:
        async with asyncio.timeout(15):  # ✅ Hard deadline: 15 seconds total
            response = await client.get(url)
            return response
    except asyncio.TimeoutError:
        log.warning(f"Deadline exceeded for {url}")
        raise
python
# ✅ Server-side Slowloris protection (in addition to reverse proxy config)
# nginx.conf
"""
client_header_timeout 10s;     # ✅ Max time to receive complete headers
client_body_timeout 10s;       # ✅ Max time to receive request body  
keepalive_timeout 15s;         # ✅ Max idle connection time
send_timeout 10s;              # ✅ Max time between two write operations

# ✅ Limit connections per IP
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
limit_conn conn_limit 10;

# ✅ Limit request rate per IP
limit_req_zone $binary_remote_addr zone=req_limit:10m rate=10r/s;
limit_req req_limit burst=20 nodelay;
"""
python
# ✅ Safe regex with timeout
import re
import signal

def regex_with_timeout(pattern, text, timeout_seconds=1):
    def handler(signum, frame):
        raise TimeoutError("Regex took too long — possible ReDoS")
    
    signal.signal(signal.SIGALRM, handler)
    signal.alarm(timeout_seconds)
    try:
        return re.match(pattern, text)
    finally:
        signal.alarm(0)

# Better: use a regex engine that guarantees linear time (RE2)
# pip install google-re2
import re2
re2.match(pattern, text)  # ✅ Always O(n), immune to ReDoS

Security Checklist for Timeouts

  • [ ] Set timeouts on EVERY external call — HTTP, database, cache, file I/O
  • [ ] Reverse proxy protection — nginx/HAProxy with client_header_timeout
  • [ ] Connection limits per IP — prevent connection pool exhaustion
  • [ ] ReDoS protection — use RE2 or set timeout on regex evaluation
  • [ ] Cascading timeout budgets — each downstream call gets a fraction of the parent's budget
  • [ ] Thread/goroutine pool limits — don't allow unbounded concurrency
  • [ ] Kill switch — ability to forcefully terminate long-running requests
  1. Status Codes — Information Leakage

What the Original Post Taught

2xx = success, 4xx = client error, 5xx = server error.

What Can Go Wrong

Verbose Error Messages Revealing Internal Details

json
// ❌ Actual error response from a poorly configured API
{
  "error": "SqlException: SELECT * FROM users WHERE id = '42' 
            AND password = 'abc123' failed at line 847 of 
            /app/services/UserService.java 
            MySQL version 8.0.31 on host db-primary.internal:3306"
}

This tells the attacker:

  • You're using MySQL 8.0.31 (they can search for vulnerabilities for this version)
  • Your database hostname is db-primary.internal:3306
  • The query structure (helps craft SQL injection)
  • The file structure of your application
  • You're using Java

User Enumeration Through Different Error Responses

text
POST /login {email: "exists@co.com", password: "wrong"}
→ 401 "Invalid password"

POST /login {email: "doesnt-exist@co.com", password: "anything"}
→ 404 "User not found"

Now the attacker knows which emails are registered. They can use this for phishing, credential stuffing, or social engineering.

Stack Traces in Production

text
"text-slate-500 font-normal italic">// ❌ Django DEBUG=True in production
{
  "error": "Traceback (most recent call last):\n
    File \"/app/views.py\", line 42, in create_user\n
    File \"/app/models.py\", line 18, in save\n
    psycopg2.errors.UniqueViolation: duplicate key value violates 
    unique constraint \"users_email_key\"\n
    DETAIL: Key (email)=(admin@example.com) already exists."
}

HTTP Security Headers Missing

Every response should include security headers, regardless of status code.

How to Fix It

python
# ✅ Secure error handling — sanitize everything

class APIError(Exception):
    def __init__(self, status_code, user_message, internal_message=None):
        self.status_code = status_code
        self.user_message = user_message
        self.internal_message = internal_message  # logged, never sent to client

@app.exception_handler(APIError)
def handle_api_error(request, exc):
    # ✅ Log the full details internally
    logger.error(f"API Error: {exc.internal_message}", extra={
        "request_id": request.state.request_id,
        "path": request.url.path,
        "status": exc.status_code
    })
    
    # ✅ Return sanitized response to client
    return JSONResponse(
        status_code=exc.status_code,
        content={
            "error": exc.user_message,
            "request_id": request.state.request_id  # for support reference
        }
    )

@app.exception_handler(Exception)
def handle_unexpected_error(request, exc):
    # ✅ NEVER leak exception details
    logger.critical(f"Unhandled exception: {exc}", exc_info=True)
    
    return JSONResponse(
        status_code=500,
        content={
            "error": "Internal server error",  # Generic message
            "request_id": request.state.request_id
        }
    )


# ✅ Login — same response regardless of failure reason
@app.post("/login")
def login(email: str, password: str):
    user = db.get_user_by_email(email)
    
    if not user or not verify_password(password, user.password_hash):
        # ✅ Same message whether user doesn't exist OR password is wrong
        # ✅ Same response time (constant-time comparison + artificial delay)
        constant_time_delay(0.1, 0.3)  # random delay between 100-300ms
        raise APIError(401, "Invalid email or password")
    
    return {"token": create_jwt(user)}

Security Headers Middleware

python
# ✅ Security headers on EVERY response
@app.middleware("http")
async def security_headers(request, call_next):
    response = await call_next(request)
    
    # Prevent MIME type sniffing
    response.headers["X-Content-Type-Options"] = "nosniff"
    
    # Prevent clickjacking
    response.headers["X-Frame-Options"] = "DENY"
    
    # XSS protection
    response.headers["X-XSS-Protection"] = "0"  # disable, use CSP instead
    
    # Content Security Policy
    response.headers["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'"
    
    # HSTS — force HTTPS
    response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload"
    
    # Don't reveal server software
    response.headers.pop("Server", None)       # ✅ Remove "Server: nginx/1.21.3"
    response.headers.pop("X-Powered-By", None) # ✅ Remove "X-Powered-By: Express"
    
    # Request ID for tracing
    response.headers["X-Request-Id"] = request.state.request_id
    
    # CORS — restrictive by default
    origin = request.headers.get("Origin")
    if origin in ALLOWED_ORIGINS:
        response.headers["Access-Control-Allow-Origin"] = origin
        response.headers["Access-Control-Allow-Credentials"] = "true"
        response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE"
        response.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"
    # ❌ NEVER: Access-Control-Allow-Origin: *  (with credentials)
    
    return response

Security Checklist for Status Codes & Error Handling

  • [ ] Never return stack traces to clients — log them server-side
  • [ ] Generic error messages for 500 errors — "Internal server error" + request ID
  • [ ] Same error message for "user not found" and "wrong password" — prevent enumeration
  • [ ] Constant response time for auth failures — prevent timing attacks
  • [ ] Security headers on every response
  • [ ] Remove server banners — Server, X-Powered-By headers
  • [ ] CORS properly configured — never use wildcard * with credentials
  • [ ] DEBUG mode OFF in production — always check this

The DevSecOps Pipeline — Catching Everything Before Production

Everything we've discussed needs to be enforced automatically. Humans forget. Automation doesn't.

Stage 1: Pre-Commit — Before Code Leaves Your Machine

yaml
# .pre-commit-config.yaml
repos:
  # ✅ Detect secrets (API keys, passwords, tokens)
  - repo: https://github.com/gitleaks/gitleaks
    hooks:
      - id: gitleaks

  # ✅ Security-focused linting
  - repo: https://github.com/PyCQA/bandit
    hooks:
      - id: bandit
        args: ["-c", "bandit.yaml"]

  # ✅ Detect hardcoded credentials
  - repo: https://github.com/trufflesecurity/trufflehog
    hooks:
      - id: trufflehog

What these catch:

python
# ❌ Gitleaks catches this
API_KEY = "sk_live_4eC39HqLyjWDarjtT1zdp7dc"

# ❌ Bandit catches this
password = "admin123"  # hardcoded credential
eval(user_input)       # code injection risk

Stage 2: CI Pipeline — Static Analysis

yaml
# GitHub Actions security pipeline
name: Security Checks

on: [push, pull_request]

jobs:
  sast:
    runs-on: ubuntu-latest
    steps:
      # ✅ Semgrep — finds security issues in code
      - uses: returntocorp/semgrep-action@v1
        with:
          config: >-
            p/owasp-top-ten
            p/jwt
            p/sql-injection
            p/xss
            p/command-injection

  dependency-scan:
    runs-on: ubuntu-latest
    steps:
      # ✅ Snyk — finds vulnerable dependencies
      - uses: snyk/actions/python@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}

  container-scan:
    runs-on: ubuntu-latest
    steps:
      # ✅ Trivy — scans container images
      - uses: aquasecurity/trivy-action@master
        with:
          image-ref: 'myapp:${{ github.sha }}'
          severity: 'CRITICAL,HIGH'
          exit-code: '1'  # fail the build on critical findings

  api-security-tests:
    runs-on: ubuntu-latest
    steps:
      - run: |
          # ✅ Custom API security test suite
          pytest tests/security/ -v --tb=short

Stage 3: Security Test Examples

python
# tests/security/test_bola.py
"""Test for Broken Object Level Authorization"""

import pytest

class TestBOLA:
    def test_user_cannot_access_other_users_data(self, client, user_a_token, user_b_id):
        """User A should NOT be able to read User B's orders"""
        response = client.get(
            f"/users/{user_b_id}/orders",
            headers={"Authorization": f"Bearer {user_a_token}"}
        )
        assert response.status_code == 403  # Must be forbidden

    def test_user_cannot_delete_other_users_data(self, client, user_a_token, user_b_id):
        """User A should NOT be able to delete User B's account"""
        response = client.delete(
            f"/users/{user_b_id}",
            headers={"Authorization": f"Bearer {user_a_token}"}
        )
        assert response.status_code == 403

    def test_sequential_id_enumeration(self, client, user_a_token):
        """Trying sequential IDs should not reveal data"""
        for user_id in range(1, 100):
            response = client.get(
                f"/users/{user_id}/profile",
                headers={"Authorization": f"Bearer {user_a_token}"}
            )
            # Should either be 403 (not your data) or 404 (doesn't exist)
            # Should NEVER be 200 for another user's data
            assert response.status_code in (403, 404)


# tests/security/test_injection.py
"""Test for injection attacks"""

class TestInjection:
    @pytest.mark.parametrize("payload", [
        "'; DROP TABLE users; --",
        "1 OR 1=1",
        "<script>alert('xss')</script>",
        "{{7*7}}",                        # SSTI
        "${7*7}",                          # Template injection
        "../../../etc/passwd",             # Path traversal
    ])
    def test_injection_in_search(self, client, auth_token, payload):
        response = client.get(
            f"/search?q={payload}",
            headers={"Authorization": f"Bearer {auth_token}"}
        )
        # Should not return 500 (indicates unhandled injection)
        assert response.status_code != 500
        # Response should not contain the payload reflected back
        assert payload not in response.text


# tests/security/test_auth.py
"""Test authentication security"""

class TestAuth:
    def test_no_token_returns_401(self, client):
        response = client.get("/users/me")
        assert response.status_code == 401

    def test_expired_token_returns_401(self, client, expired_token):
        response = client.get(
            "/users/me",
            headers={"Authorization": f"Bearer {expired_token}"}
        )
        assert response.status_code == 401

    def test_alg_none_token_rejected(self, client):
        """JWT with alg:none must be rejected"""
        none_token = create_unsigned_jwt({"sub": "1", "role": "admin"})
        response = client.get(
            "/users/me",
            headers={"Authorization": f"Bearer {none_token}"}
        )
        assert response.status_code == 401

    def test_login_timing_is_constant(self, client):
        """Login with valid vs invalid email should take same time"""
        import time
        
        times_valid_email = []
        times_invalid_email = []
        
        for _ in range(20):
            start = time.time()
            client.post("/login", json={
                "email": "existing@example.com",
                "password": "wrongpassword"
            })
            times_valid_email.append(time.time() - start)
            
            start = time.time()
            client.post("/login", json={
                "email": "nonexistent@example.com",
                "password": "wrongpassword"
            })
            times_invalid_email.append(time.time() - start)
        
        avg_valid = sum(times_valid_email) / len(times_valid_email)
        avg_invalid = sum(times_invalid_email) / len(times_invalid_email)
        
        # Difference should be less than 50ms (constant-time)
        assert abs(avg_valid - avg_invalid) < 0.05


# tests/security/test_mass_assignment.py
class TestMassAssignment:
    def test_cannot_set_role_via_api(self, client, auth_token):
        response = client.post(
            "/users",
            json={"name": "Hacker", "email": "h@ck.er", "role": "admin"},
            headers={"Authorization": f"Bearer {auth_token}"}
        )
        if response.status_code == 201:
            user = response.json()
            assert user.get("role") != "admin"  # role must be ignored


# tests/security/test_rate_limiting.py
class TestRateLimiting:
    def test_login_rate_limited(self, client):
        """Login endpoint should be rate limited"""
        responses = []
        for _ in range(20):
            r = client.post("/login", json={
                "email": "test@test.com",
                "password": "wrong"
            })
            responses.append(r.status_code)
        
        # At least some should be 429
        assert 429 in responses, "Login endpoint is not rate limited!"

Stage 4: Dynamic Testing in Staging

yaml
# OWASP ZAP automated scan
zap:
  target: https://staging.yourapp.com
  apis:
    - openapi: https://staging.yourapp.com/openapi.json
  policies:
    - sql-injection
    - xss
    - ssrf
    - path-traversal
    - authentication-bypass
  authentication:
    type: jwt
    login_url: https://staging.yourapp.com/login
    credentials:
      email: security-test@yourapp.com
      password: ${SECURITY_TEST_PASSWORD}

Stage 5: Runtime Monitoring in Production

What to Log for Security

python
# ✅ Structured security logging
import structlog

security_logger = structlog.get_logger("security")

# Log auth events
security_logger.info("auth.login.success", 
    user_id="42", 
    ip=request.client.host,
    user_agent=request.headers.get("user-agent"))

security_logger.warning("auth.login.failed",
    email=email,  # log email, NOT password
    ip=request.client.host,
    reason="invalid_password",
    attempt_count=failed_count)

# Log authorization failures  
security_logger.warning("authz.denied",
    user_id="42",
    resource="/admin/users",
    action="DELETE",
    reason="insufficient_role")

# Log rate limit hits
security_logger.warning("rate_limit.exceeded",
    ip=request.client.host,
    endpoint="/login",
    limit="5/minute")

# ❌ NEVER log:
# - Passwords (even failed ones)
# - Full credit card numbers
# - Session tokens / JWT values
# - PII in plain text (encrypt or mask)

Secrets Management — The Forgotten Fundamental

This topic spans all 12 concepts. API keys, database passwords, JWT signing keys, webhook secrets — they all need to be managed securely.

yaml
# ✅ .gitignore — ALWAYS include these
.env
.env.*
*.key
*.pem
secrets/
credentials.json
bash
"text-slate-500 font-normal italic"># ✅ If you accidentally committed a secret:
"text-slate-500 font-normal italic"># 1. Rotate the secret IMMEDIATELY (the old one is compromised forever — Git history)
"text-slate-500 font-normal italic"># 2. Remove from Git history
git filter-branch --force --index-filter \
  "git rm --cached --ignore-unmatch .env" \
  --prune-empty --tag-name-filter cat -- --all
"text-slate-500 font-normal italic"># 3. Force push (coordinate with team)
"text-slate-500 font-normal italic"># 4. Consider the old secret permanently compromised

The Complete Security Checklist — All 12 Concepts

Use this as a review before every deployment:

🏗️ Design Phase

  • [ ] Threat model completed for all endpoints
  • [ ] OWASP API Top 10 reviewed against design
  • [ ] Authentication and authorization model defined
  • [ ] Rate limiting strategy per endpoint defined
  • [ ] Input validation schemas defined

🔐 REST & Data

  • [ ] Input validated on all endpoints (type, length, range, format)
  • [ ] Output fields explicitly selected (no SELECT *)
  • [ ] Mass assignment prevented (whitelist fields)
  • [ ] BOLA checks on every resource access
  • [ ] UUIDs used instead of sequential IDs

🔑 Authentication

  • [ ] JWT algorithm whitelist enforced (no none, no HS256 with RSA keys)
  • [ ] Short-lived access tokens (≤ 15 minutes)
  • [ ] Refresh token rotation with reuse detection
  • [ ] HttpOnly + Secure + SameSite cookies
  • [ ] MFA on sensitive operations
  • [ ] Account lockout with notification

🚦 Rate Limiting & Pagination

  • [ ] Rate limits on all endpoints, stricter on auth endpoints
  • [ ] Per-user, per-IP, and global rate limits layered
  • [ ] Maximum page size enforced
  • [ ] Cursors signed/encrypted (tamper-proof)
  • [ ] Total count hidden if sensitive

🔄 Webhooks

  • [ ] SSRF prevention (URL validation, IP range blocking)
  • [ ] Signature verification with constant-time comparison
  • [ ] Timestamp validation (reject > 5 min old)
  • [ ] Event deduplication by ID
  • [ ] HTTPS only for webhook delivery

⚡ gRPC & GraphQL

  • [ ] TLS/mTLS enforced on gRPC
  • [ ] Reflection API disabled in production
  • [ ] GraphQL introspection disabled in production
  • [ ] Query depth + complexity + alias limits set
  • [ ] Field-level authorization in resolvers

🔁 Retries & Timeouts

  • [ ] Timeouts on every external call
  • [ ] Circuit breakers on external dependencies
  • [ ] Retry budgets to prevent storms
  • [ ] Slowloris protection at reverse proxy level
  • [ ] ReDoS-safe regex patterns (or RE2)

📡 Status Codes & Responses

  • [ ] No stack traces in production responses
  • [ ] Generic messages for 500 errors
  • [ ] Same error message for "user not found" vs "wrong password"
  • [ ] Security headers on all responses
  • [ ] Server banners removed
  • [ ] CORS restrictively configured

🏭 Pipeline & Operations

  • [ ] Pre-commit hooks for secret detection
  • [ ] SAST in CI pipeline
  • [ ] Dependency vulnerability scanning
  • [ ] Container image scanning
  • [ ] DAST in staging environment
  • [ ] API-specific security tests (BOLA, injection, auth)
  • [ ] Secrets in vault, not code
  • [ ] Security logging with SIEM integration
  • [ ] Runtime anomaly alerting
  • [ ] API inventory maintained (no shadow APIs)
  • [ ] Deprecated versions fully shut down or equally secured

Final Thought

Security is not a feature you add at the end. It's a quality you build from the start. Every one of the 12 API concepts has a security dimension. The developer who understands both the happy path and the attack path writes code that survives the real world.

The attackers are reading the same documentation you are. The difference is: they're looking for what you forgot.

Don't forget.