API Security Checklist: Comprehensive Hardening Guide

Type: Software Reference Confidence: 0.93 Sources: 7 Verified: 2026-02-27 Freshness: 2026-02-27

TL;DR

Constraints

Quick Reference

OWASP API Security Top 10 (2023)

#VulnerabilityOWASP IDRiskVulnerable PatternSecure Pattern
1Broken Object Level AuthorizationAPI1:2023CriticalGET /api/users/123/orders without ownership checkVerify req.user.id === resource.owner_id on every request
2Broken AuthenticationAPI2:2023CriticalNo rate limit on /login; tokens never expireRate-limit auth endpoints; use short-lived JWTs + refresh tokens
3Broken Object Property Level AuthorizationAPI3:2023HighAPI returns all fields including is_admin, ssnAllowlist response fields per role; block mass assignment
4Unrestricted Resource ConsumptionAPI4:2023HighNo pagination limit; unbounded file uploadsEnforce max_page_size, upload size limits, request timeouts
5Broken Function Level AuthorizationAPI5:2023HighPUT /api/admin/users accessible to regular usersCheck role/permissions per endpoint, not just authentication
6Unrestricted Access to Sensitive Business FlowsAPI6:2023MediumNo bot protection on /api/checkoutCAPTCHA, rate limiting, device fingerprinting on critical flows
7Server-Side Request Forgery (SSRF)API7:2023Highfetch(user_supplied_url) without validationAllowlist target hosts; block private IP ranges
8Security MisconfigurationAPI8:2023MediumCORS Access-Control-Allow-Origin: *; verbose errorsRestrict CORS origins; return generic errors; disable debug mode
9Improper Inventory ManagementAPI9:2023MediumDeprecated /api/v1/ still active with no authMaintain API inventory; decommission old versions
10Unsafe Consumption of APIsAPI10:2023MediumTrusting third-party API responses without validationValidate and sanitize all external API data before use

Security Headers for API Responses

#HeaderValuePurpose
1Strict-Transport-Securitymax-age=31536000; includeSubDomainsForce HTTPS
2X-Content-Type-OptionsnosniffPrevent MIME sniffing
3X-Frame-OptionsDENYBlock framing
4Cache-Controlno-storePrevent caching sensitive responses
5Content-Security-Policyframe-ancestors 'none'Block iframe embedding
6Content-TypeMatch actual response formatPrevent 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

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

StandardVersionStatusKey Changes
OWASP API Security Top 102023CurrentReplaced 2019 list; added SSRF, unsafe consumption, business flow abuse
OWASP API Security Top 102019SupersededOriginal list; mass assignment and injection as separate items
OAuth 2.0RFC 6749CurrentAuthorization framework; use with PKCE (RFC 7636)
OAuth 2.1DraftIn progressConsolidates PKCE, deprecates implicit flow
JWTRFC 7519CurrentUse RS256/ES256; avoid HS256 in distributed systems
TLS1.3 (RFC 8446)RecommendedFaster handshake; removed insecure ciphers
TLS1.2 (RFC 5246)Minimum required1.0 and 1.1 deprecated by RFC 8996

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Building any HTTP API exposed to the internetInternal-only service on a trusted private networkNetwork-level access controls (still apply auth)
API handles user data, payments, or PIIFully public read-only data with no write operationsAPI keys for rate limiting + HTTPS may suffice
Microservice-to-microservice communicationSingle monolith with no external API surfaceStandard web app security practices
API consumed by third-party developersPrototype/PoC in isolated environmentPrioritize, but accept temporary risk with hardening plan

Important Caveats

Related Units