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:
- What can go wrong? — the attack vectors
- What does it look like? — real exploit scenarios
- 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:
| # | Risk | One-liner |
|---|---|---|
| API1 | Broken Object Level Authorization (BOLA) | Accessing other users' data by changing IDs |
| API2 | Broken Authentication | Weak login, token, or session mechanisms |
| API3 | Broken Object Property Level Authorization | Seeing/changing fields you shouldn't |
| API4 | Unrestricted Resource Consumption | No rate limits, no pagination caps |
| API5 | Broken Function Level Authorization | Calling admin endpoints as regular user |
| API6 | Unrestricted Access to Sensitive Business Flows | Automating things meant for humans |
| API7 | Server Side Request Forgery (SSRF) | Making the server call internal resources |
| API8 | Security Misconfiguration | Debug mode on, CORS open, headers missing |
| API9 | Improper Inventory Management | Old/shadow/deprecated APIs still accessible |
| API10 | Unsafe Consumption of APIs | Trusting third-party API responses blindly |
Keep this table handy. Almost every vulnerability we discuss maps to one of these.
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:
// 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.
GET /users/42/orders ← Alice views her orders
GET /users/43/orders ← Alice changes 42 to 43, sees Bob's ordersThe 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:
POST /users/42
X-HTTP-Method-Override: DELETEIf your firewall only blocks DELETE requests but the framework honors the override header, the user gets deleted via a POST.
Verb Tampering
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
# ❌ 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)# ❌ 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)
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
-- ✅ 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 resultOr in application code with distributed locking:
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
raiseIdempotency Key Manipulation
What if an attacker sends someone else's idempotency key?
"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 paymentFix: 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
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)
GET /users?limit=999999999If 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
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 scrapedCursor 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
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)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,...
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)
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)
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:
| Endpoint | Limit | Why |
|---|---|---|
POST /login | 5/minute per IP+email | Brute force prevention |
POST /forgot-password | 3/hour per email | Abuse prevention |
GET /products | 100/minute per user | Normal usage |
POST /payments | 10/minute per user | Financial operations |
GET /search | 30/minute per IP | Anti-scraping |
Sensitive Endpoint Hardening
For login endpoints specifically:
# ✅ 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
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-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
# ✅ 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
# 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: mTLSSecurity 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
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-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 refundsSignature Bypass
# ❌ 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 differencesHow to Fix It
SSRF Prevention:
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 TrueReplay Prevention:
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
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.
"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
# ✅ 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# ✅ 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
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:
{
__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)
{
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:
"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)
"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
# ✅ 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:
# ✅ 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.ssnSecurity 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?"
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:
// Attacker crafts a JWT with alg: "none"
{
"alg": "none", ← tells server "no signature needed"
"typ": "JWT"
}
.
{
"sub": "42",
"role": "admin" ← escalated privileges
}
.
← empty signatureIf 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:
- Download the public key (it's public!)
- Forge a JWT signed with HS256 using the public key as the HMAC secret
- 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 Location | XSS 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
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# ✅ 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_refreshSecure Cookie Configuration
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
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:
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 requestsHow to Fix It
# ✅ 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
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:
# ❌ 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 hoursThread Pool Exhaustion Without Timeouts
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
# ✅ 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# ✅ 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;
"""# ✅ 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 ReDoSSecurity 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
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
// ❌ 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
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-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
# ✅ 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
# ✅ 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 responseSecurity 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
# .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: trufflehogWhat these catch:
# ❌ Gitleaks catches this
API_KEY = "sk_live_4eC39HqLyjWDarjtT1zdp7dc"
# ❌ Bandit catches this
password = "admin123" # hardcoded credential
eval(user_input) # code injection riskStage 2: CI Pipeline — Static Analysis
# 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=shortStage 3: Security Test Examples
# 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
# 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
# ✅ 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.
# ✅ .gitignore — ALWAYS include these
.env
.env.*
*.key
*.pem
secrets/
credentials.json"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 compromisedThe 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.