API Security Checklist: Comprehensive Hardening Guide
What is the API security checklist?
TL;DR
- Bottom line: Secure APIs require layered defenses across authentication, authorization, input validation, rate limiting, output filtering, and monitoring -- no single control is sufficient.
- Key tool/command:
helmet()(Node.js), OWASP ZAP API scan, orbearer scanfor automated API security testing. - Watch out for: Broken Object Level Authorization (BOLA) accounts for ~40% of API attacks and is the #1 OWASP API risk -- always verify the requesting user owns the resource.
- Works with: Any HTTP-based API (REST, GraphQL, gRPC-web). Framework-specific examples for Python/FastAPI, Node.js/Express, and Go.
Constraints
- NEVER expose API endpoints without authentication unless explicitly designed as public
- ALWAYS enforce HTTPS with TLS 1.2+ -- plaintext HTTP must be rejected, not redirected
- NEVER include secrets (API keys, tokens, passwords) in URLs -- use Authorization header or request body
- Rate limiting MUST be applied per-user/per-key, not just per-IP -- shared NATs make IP-only limiting ineffective
- Input validation MUST happen server-side -- client-side validation is a UX feature, not a security control
- NEVER return stack traces, SQL errors, or internal paths in API error responses
Quick Reference
OWASP API Security Top 10 (2023)
| # | Vulnerability | OWASP ID | Risk | Vulnerable Pattern | Secure Pattern |
|---|---|---|---|---|---|
| 1 | Broken Object Level Authorization | API1:2023 | Critical | GET /api/users/123/orders without ownership check | Verify req.user.id === resource.owner_id on every request |
| 2 | Broken Authentication | API2:2023 | Critical | No rate limit on /login; tokens never expire | Rate-limit auth endpoints; use short-lived JWTs + refresh tokens |
| 3 | Broken Object Property Level Authorization | API3:2023 | High | API returns all fields including is_admin, ssn | Allowlist response fields per role; block mass assignment |
| 4 | Unrestricted Resource Consumption | API4:2023 | High | No pagination limit; unbounded file uploads | Enforce max_page_size, upload size limits, request timeouts |
| 5 | Broken Function Level Authorization | API5:2023 | High | PUT /api/admin/users accessible to regular users | Check role/permissions per endpoint, not just authentication |
| 6 | Unrestricted Access to Sensitive Business Flows | API6:2023 | Medium | No bot protection on /api/checkout | CAPTCHA, rate limiting, device fingerprinting on critical flows |
| 7 | Server-Side Request Forgery (SSRF) | API7:2023 | High | fetch(user_supplied_url) without validation | Allowlist target hosts; block private IP ranges |
| 8 | Security Misconfiguration | API8:2023 | Medium | CORS Access-Control-Allow-Origin: *; verbose errors | Restrict CORS origins; return generic errors; disable debug mode |
| 9 | Improper Inventory Management | API9:2023 | Medium | Deprecated /api/v1/ still active with no auth | Maintain API inventory; decommission old versions |
| 10 | Unsafe Consumption of APIs | API10:2023 | Medium | Trusting third-party API responses without validation | Validate and sanitize all external API data before use |
Security Headers for API Responses
| # | Header | Value | Purpose |
|---|---|---|---|
| 1 | Strict-Transport-Security | max-age=31536000; includeSubDomains | Force HTTPS |
| 2 | X-Content-Type-Options | nosniff | Prevent MIME sniffing |
| 3 | X-Frame-Options | DENY | Block framing |
| 4 | Cache-Control | no-store | Prevent caching sensitive responses |
| 5 | Content-Security-Policy | frame-ancestors 'none' | Block iframe embedding |
| 6 | Content-Type | Match actual response format | Prevent type confusion |
Decision Tree
START: What aspect of API security are you implementing?
├── Authentication?
│ ├── Public API with rate limiting only → API keys + rate limiter
│ ├── User-facing API → OAuth 2.0 + PKCE (browser) or client_credentials (service)
│ └── Service-to-service → mTLS or JWT with shared secret
├── Authorization?
│ ├── Resource-level access (BOLA) → Check ownership on EVERY request
│ ├── Function-level access → RBAC/ABAC middleware per endpoint
│ └── Property-level access → Response field allowlist per role
├── Input validation?
│ ├── Structured data (JSON) → JSON Schema validation middleware
│ ├── File uploads → Size limit + type allowlist + virus scan
│ └── Query parameters → Type coercion + range validation
├── Rate limiting?
│ ├── Authentication endpoints → Strict: 5-10 req/min per user
│ ├── Regular endpoints → Moderate: 100-1000 req/min per key
│ └── Public/search endpoints → Generous: 1000+ req/min per IP
├── Logging & monitoring?
│ ├── YES → Structured JSON logs + SIEM integration + alerting
│ └── NO → Implement immediately
└── DEFAULT → Start with authentication + HTTPS + rate limiting, then layer remaining controls
Step-by-Step Guide
1. Enforce HTTPS and security headers
Reject plaintext HTTP at the load balancer or reverse proxy level. Configure TLS 1.2+ with strong cipher suites. Add security headers to all API responses. [src2]
server {
listen 80;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Cache-Control "no-store" always;
}
Verify: curl -sI https://your-api.com/health | grep -i strict-transport -- should return the HSTS header.
2. Implement authentication on all endpoints
Use OAuth 2.0 with PKCE for browser clients, client_credentials for service-to-service, and validate JWTs on every request. [src1] [src2]
from fastapi import Depends, HTTPException, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import jwt # PyJWT ^2.8.0
security = HTTPBearer()
async def verify_token(credentials: HTTPAuthorizationCredentials = Security(security)):
try:
payload = jwt.decode(credentials.credentials,
os.environ["JWT_SECRET"], algorithms=["RS256"],
audience="your-api", issuer="your-auth-server")
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
Verify: curl -H "Authorization: Bearer invalid" https://your-api.com/resource -- should return 401.
3. Add object-level authorization checks
Check that the authenticated user has permission to access the specific resource on every request. This prevents BOLA, the #1 API vulnerability. [src1]
@app.get("/api/orders/{order_id}")
async def get_order(order_id: str, user=Depends(verify_token)):
order = await db.fetch_order(order_id)
if not order:
raise HTTPException(status_code=404, detail="Not found")
if order["user_id"] != user["sub"]: # CRITICAL: ownership check
raise HTTPException(status_code=403, detail="Forbidden")
return order
Verify: Request another user's resource with a valid token -- should return 403.
4. Validate all input with schemas
Use schema validation to reject unexpected fields, types, and values. Validate Content-Type headers. Enforce size limits. [src2] [src3]
from pydantic import BaseModel, Field, constr
class CreateOrderRequest(BaseModel):
product_id: constr(pattern=r'^[a-zA-Z0-9-]{1,50}$')
quantity: int = Field(ge=1, le=100)
shipping_address: constr(max_length=500)
Verify: Send {"product_id": "'; DROP TABLE--", "quantity": -1} -- should return 422.
5. Apply rate limiting
Rate-limit per user/API key, not just per IP. Use stricter limits on authentication endpoints. Return 429 with Retry-After header. [src2] [src4]
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
@app.post("/api/auth/login")
@limiter.limit("5/minute") # Strict for auth
async def login(request: Request): ...
@app.get("/api/orders")
@limiter.limit("100/minute") # Moderate for data
async def list_orders(request: Request): ...
Verify: Send 6 rapid login requests -- the 6th should return 429 Too Many Requests.
6. Configure structured logging and monitoring
Log all authentication events, authorization failures, and unusual patterns. Use structured JSON format. Never log sensitive data. [src6]
import logging, json
from datetime import datetime, timezone
class SecurityLogger:
def __init__(self):
self.logger = logging.getLogger("api.security")
def log_auth_event(self, event_type, user_id, ip, success, detail=""):
self.logger.info(json.dumps({
"timestamp": datetime.now(timezone.utc).isoformat(),
"event": event_type, "user_id": user_id,
"ip": ip, "success": success, "detail": detail
}))
Verify: Check logs after a failed login -- should contain structured JSON with event type and IP, but no password or token values.
Code Examples
Python/FastAPI: Complete Security Middleware Stack
# Input: HTTP request to any API endpoint
# Output: Secured response with headers, auth, rate limiting
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
app = FastAPI()
# CORS -- restrict origins (never use "*" in production)
app.add_middleware(CORSMiddleware,
allow_origins=["https://your-app.com"],
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)
# Security headers middleware
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Cache-Control"] = "no-store"
response.headers["Strict-Transport-Security"] = "max-age=31536000"
return response
app.add_middleware(SecurityHeadersMiddleware)
Node.js/Express: Helmet + Rate Limiting + Auth
// Input: HTTP request to Express API
// Output: Secured response with all hardening applied
const express = require('express'); // ^4.21.0
const helmet = require('helmet'); // ^8.0.0
const rateLimit = require('express-rate-limit'); // ^7.4.0
const jwt = require('jsonwebtoken'); // ^9.0.0
const app = express();
app.use(helmet({ contentSecurityPolicy: false }));
const apiLimiter = rateLimit({
windowMs: 60 * 1000, max: 100,
standardHeaders: true, legacyHeaders: false,
message: { error: 'Too many requests' }
});
app.use('/api/', apiLimiter);
function authenticate(req, res, next) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return res.status(401).json({ error: 'Missing token' });
try {
req.user = jwt.verify(token, process.env.JWT_SECRET,
{ algorithms: ['RS256'], audience: 'your-api' });
next();
} catch { res.status(401).json({ error: 'Invalid token' }); }
}
app.use('/api/protected', authenticate);
Go: Chi Router with Auth + Rate Limiting Middleware
// Input: HTTP request to Go API
// Output: Secured response with middleware chain
package main
import (
"net/http"
"os"
"time"
"github.com/go-chi/chi/v5" // v5.1.0
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/httprate" // v0.9.0
"github.com/go-chi/jwtauth/v5" // v5.3.0
)
var tokenAuth = jwtauth.New("HS256",
[]byte(os.Getenv("JWT_SECRET")), nil)
func main() {
r := chi.NewRouter()
r.Use(middleware.Logger, middleware.Recoverer, securityHeaders)
r.Use(httprate.LimitByIP(100, time.Minute))
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(tokenAuth))
r.Use(jwtauth.Authenticator(tokenAuth))
r.Get("/api/orders", listOrders)
})
http.ListenAndServeTLS(":443", "cert.pem", "key.pem", r)
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Strict-Transport-Security", "max-age=31536000")
w.Header().Set("Cache-Control", "no-store")
next.ServeHTTP(w, r)
})
}
Anti-Patterns
Wrong: Relying on API keys as the only authentication
# BAD -- API keys alone are insufficient for user-facing APIs
@app.get("/api/user/profile")
async def get_profile(api_key: str = Query(...)):
user = await db.find_by_api_key(api_key)
return user # Keys are shared secrets, easily leaked in logs/URLs
Correct: OAuth 2.0 tokens with proper validation
# GOOD -- JWT with audience, issuer, and expiry validation
@app.get("/api/user/profile")
async def get_profile(user=Depends(verify_token)):
return await db.find_by_id(user["sub"])
# Token is short-lived, validated, and never in URLs
Wrong: Trusting client-provided IDs without ownership check
// BAD -- BOLA vulnerability: no ownership verification
app.get('/api/users/:userId/documents', async (req, res) => {
const docs = await db.getDocuments(req.params.userId);
res.json(docs); // Any authenticated user can read any user's documents
});
Correct: Verify resource ownership on every request
// GOOD -- check that authenticated user owns the resource
app.get('/api/users/:userId/documents', authenticate, async (req, res) => {
if (req.user.id !== req.params.userId)
return res.status(403).json({ error: 'Forbidden' });
const docs = await db.getDocuments(req.params.userId);
res.json(docs);
});
Wrong: Returning all object properties in responses
# BAD -- mass data exposure (API3:2023)
@app.get("/api/users/{user_id}")
async def get_user(user_id: str):
user = await db.get_user(user_id)
return user # Returns password_hash, ssn, is_admin...
Correct: Allowlist response fields per role
# GOOD -- explicit field allowlist per role
PUBLIC_FIELDS = {"id", "name", "avatar_url"}
ADMIN_FIELDS = PUBLIC_FIELDS | {"email", "role", "created_at"}
@app.get("/api/users/{user_id}")
async def get_user(user_id: str, user=Depends(verify_token)):
record = await db.get_user(user_id)
fields = ADMIN_FIELDS if user.get("role") == "admin" else PUBLIC_FIELDS
return {k: v for k, v in record.items() if k in fields}
Wrong: Verbose error messages that leak internals
// BAD -- leaks database schema and query details
app.use((err, req, res, next) => {
res.status(500).json({
error: err.message, // "relation 'users' does not exist"
stack: err.stack, // Full stack trace with file paths
query: err.sql // "SELECT * FROM users WHERE id = $1"
});
});
Correct: Generic errors externally, detailed logging internally
// GOOD -- generic response + internal structured logging
app.use((err, req, res, next) => {
const errorId = crypto.randomUUID();
logger.error({ errorId, message: err.message, stack: err.stack,
path: req.path, method: req.method, userId: req.user?.id });
res.status(500).json({
error: 'Internal server error', reference: errorId
});
});
Common Pitfalls
- BOLA via predictable IDs: Using sequential integers for resource IDs makes enumeration trivial. Fix: Use UUIDs and always verify ownership regardless of ID format. [src1]
- JWT
alg: noneattack: Libraries that accept unsigned JWTs when configured for HMAC/RSA. Fix: Always specifyalgorithmsparameter explicitly; never usealg: none. [src2] - CORS wildcard on authenticated APIs:
Access-Control-Allow-Origin: *misconfigured with credential reflection. Fix: Allowlist specific origins; never echo the Origin header blindly. [src2] - Rate limiting bypassed via header spoofing: Trusting
X-Forwarded-Forwithout verifying the proxy chain. Fix: Configure trusted proxy count; use the rightmost untrusted IP. [src4] - Missing pagination limits: Endpoints returning unbounded results enabling DoS via
?limit=999999. Fix: Enforcemax_page_sizeserver-side (e.g., max 100 items). [src1] - Stale API versions left running: Deprecated
/api/v1/with weaker auth remains accessible. Fix: Maintain an API inventory; decommission and block old versions. [src1] - Logging tokens and passwords: Security logs that include Bearer tokens or request bodies with passwords. Fix: Sanitize logs; redact Authorization headers and sensitive fields. [src6]
- Third-party API data trusted blindly: External API responses injected into SQL or HTML without validation. Fix: Validate and sanitize all external data the same as user input. [src1]
Diagnostic Commands
# Check TLS configuration and certificate
openssl s_client -connect your-api.com:443 -tls1_2 </dev/null 2>/dev/null | head -20
# Check security headers on API response
curl -sI https://your-api.com/api/health | grep -iE 'strict-transport|x-content-type|x-frame|cache-control'
# Test rate limiting (should get 429 after limit)
for i in $(seq 1 20); do curl -s -o /dev/null -w "%{http_code}\n" https://your-api.com/api/auth/login -X POST -d '{}'; done
# Scan API with OWASP ZAP
docker run -t ghcr.io/zaproxy/zaproxy:stable zap-api-scan.py \
-t https://your-api.com/openapi.json -f openapi -r report.html
# Test CORS misconfiguration
curl -sI -H "Origin: https://evil.com" https://your-api.com/api/data | grep -i access-control
# Audit JWT header
echo "YOUR_JWT" | cut -d. -f1 | base64 -d 2>/dev/null | python3 -m json.tool
Version History & Compatibility
| Standard | Version | Status | Key Changes |
|---|---|---|---|
| OWASP API Security Top 10 | 2023 | Current | Replaced 2019 list; added SSRF, unsafe consumption, business flow abuse |
| OWASP API Security Top 10 | 2019 | Superseded | Original list; mass assignment and injection as separate items |
| OAuth 2.0 | RFC 6749 | Current | Authorization framework; use with PKCE (RFC 7636) |
| OAuth 2.1 | Draft | In progress | Consolidates PKCE, deprecates implicit flow |
| JWT | RFC 7519 | Current | Use RS256/ES256; avoid HS256 in distributed systems |
| TLS | 1.3 (RFC 8446) | Recommended | Faster handshake; removed insecure ciphers |
| TLS | 1.2 (RFC 5246) | Minimum required | 1.0 and 1.1 deprecated by RFC 8996 |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Building any HTTP API exposed to the internet | Internal-only service on a trusted private network | Network-level access controls (still apply auth) |
| API handles user data, payments, or PII | Fully public read-only data with no write operations | API keys for rate limiting + HTTPS may suffice |
| Microservice-to-microservice communication | Single monolith with no external API surface | Standard web app security practices |
| API consumed by third-party developers | Prototype/PoC in isolated environment | Prioritize, but accept temporary risk with hardening plan |
Important Caveats
- This checklist covers HTTP-based REST APIs primarily; GraphQL and gRPC have additional attack surfaces (query depth, field-level auth, streaming abuse) requiring specific controls
- API gateways provide centralized rate limiting and auth but are not a substitute for application-level authorization checks -- BOLA must be checked in your code
- Rate limiting by IP address alone is insufficient with shared NAT, corporate proxies, or IPv6 privacy extensions -- always layer with per-user/per-key limits
- OAuth 2.1 (draft) deprecates the implicit grant flow and requires PKCE for all public clients -- plan to migrate if using implicit flow
- mTLS provides strong service-to-service authentication but adds operational complexity -- consider a service mesh to simplify certificate management
- Compliance requirements (PCI-DSS, HIPAA, GDPR) may impose additional API security controls beyond this checklist