helmet() (Node.js), OWASP ZAP API scan, or bearer scan for automated API security testing.| # | 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 |
| # | 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 |
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
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.
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.
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.
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.
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.
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.
# 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)
// 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);
// 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)
})
}
# 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
# 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
// 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
});
// 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);
});
# 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...
# 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}
// 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"
});
});
// 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
});
});
alg: none attack: Libraries that accept unsigned JWTs when configured for HMAC/RSA. Fix: Always specify algorithms parameter explicitly; never use alg: none. [src2]Access-Control-Allow-Origin: * misconfigured with credential reflection. Fix: Allowlist specific origins; never echo the Origin header blindly. [src2]X-Forwarded-For without verifying the proxy chain. Fix: Configure trusted proxy count; use the rightmost untrusted IP. [src4]?limit=999999. Fix: Enforce max_page_size server-side (e.g., max 100 items). [src1]/api/v1/ with weaker auth remains accessible. Fix: Maintain an API inventory; decommission and block old versions. [src1]# 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
| 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 |
| 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 |