API Security Hardening: OWASP Top 10 Implementation
Secure your APIs against the OWASP API Security Top 10. Covers authentication, authorization, rate limiting, input validation, and security testing with practical code examples.
APIs are the #1 attack surface in modern applications. Every mobile app, SPA, microservice, and third-party integration communicates through APIs. The OWASP API Security Top 10 identifies the most critical risks based on real-world breach data. This guide translates each risk into actionable security controls with production-ready implementation code.
The distinction between “application security” and “API security” is important: APIs expose raw business logic and data, often with less protection than web UIs. A web form might show you 10 records; the underlying API returns 10,000. A web form validates input client-side; the API must validate server-side because any client can call it directly.
OWASP API Security Top 10
| # | Risk | Severity | Real-World Impact |
|---|---|---|---|
| 1 | Broken Object Level Authorization (BOLA) | Critical | User A accesses User B’s data by changing an ID in the URL |
| 2 | Broken Authentication | Critical | Weak tokens, no MFA, credential stuffing |
| 3 | Broken Object Property Level Authorization | High | Mass assignment — user sets admin=true in request body |
| 4 | Unrestricted Resource Consumption | Medium | DoS via expensive queries, no pagination limits |
| 5 | Broken Function Level Authorization | High | Regular user accesses admin endpoints |
| 6 | Unrestricted Access to Sensitive Flows | Medium | Bot abuse of checkout, registration, or password reset |
| 7 | Server Side Request Forgery (SSRF) | High | API fetches internal URLs, exposing cloud metadata |
| 8 | Security Misconfiguration | Medium | Verbose errors, default credentials, CORS misconfigured |
| 9 | Improper Inventory Management | Low | Shadow APIs, deprecated endpoints still live |
| 10 | Unsafe Consumption of APIs | Medium | Blindly trusting third-party API responses |
#1: Broken Object Level Authorization (BOLA)
The single most common API vulnerability. Every endpoint that accepts a resource identifier (ID, slug, UUID) must verify that the requesting user has permission to access that specific resource.
# ❌ VULNERABLE: No ownership check — anyone can access any order
@app.get("/api/orders/{order_id}")
def get_order(order_id: str):
return db.get_order(order_id) # Missing authorization check
# ✅ SECURE: Verify ownership before returning data
@app.get("/api/orders/{order_id}")
def get_order(order_id: str, current_user: User = Depends(get_current_user)):
order = db.get_order(order_id)
if order is None:
raise HTTPException(404)
if order.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(403, "Access denied")
return order
BOLA Prevention Patterns
| Pattern | When to Use | Implementation |
|---|---|---|
| Direct ownership check | User-owned resources (orders, profiles) | resource.user_id == current_user.id |
| Team/org membership | Team resources (projects, dashboards) | Check user’s team membership in DB |
| RBAC with resource scope | Complex permissions | Role + resource-level permission check |
| UUIDs instead of sequential IDs | All resources | Prevents ID enumeration attacks |
#2: Broken Authentication
Authentication is the front door of your API. Weak authentication means everything behind it is exposed.
# Secure JWT implementation with short-lived tokens and revocation
from jose import jwt
from datetime import datetime, timedelta
SECRET_KEY = os.environ["JWT_SECRET"] # Never hardcode
ALGORITHM = "HS256"
def create_access_token(user_id: str, roles: list, expires_minutes: int = 15) -> str:
"""Short-lived access token — 15 minutes default"""
payload = {
"sub": user_id,
"roles": roles,
"exp": datetime.utcnow() + timedelta(minutes=expires_minutes),
"iat": datetime.utcnow(),
"jti": str(uuid.uuid4()), # Unique token ID for revocation
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def create_refresh_token(user_id: str, expires_days: int = 7) -> str:
"""Longer-lived refresh token — stored securely, used to get new access tokens"""
payload = {
"sub": user_id,
"type": "refresh",
"exp": datetime.utcnow() + timedelta(days=expires_days),
"jti": str(uuid.uuid4()),
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def verify_token(token: str) -> dict:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
if is_token_revoked(payload["jti"]):
raise HTTPException(401, "Token revoked")
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(401, "Token expired")
except jwt.JWTError:
raise HTTPException(401, "Invalid token")
Authentication Checklist
| Control | Status | Notes |
|---|---|---|
| Access token expiry ≤ 15 min | Required | Limits exposure window |
| Refresh token with secure storage | Required | HttpOnly cookie or secure storage |
| Token revocation capability | Required | Blocklist by JTI on logout/password change |
| Brute force protection | Required | Rate limiting on auth endpoints (see below) |
| Password hashing (bcrypt/argon2) | Required | Never store plaintext or MD5/SHA1 |
| MFA for sensitive operations | Recommended | Payment, admin, profile changes |
#4: Rate Limiting
Without rate limiting, attackers can brute-force credentials, scrape data, and cause denial-of-service. Different endpoints need different limits.
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
@app.post("/api/login")
@limiter.limit("5/minute") # Brute force prevention — very strict
def login(request: Request, credentials: LoginRequest):
return authenticate(credentials)
@app.get("/api/search")
@limiter.limit("30/minute") # Search abuse prevention — moderate
def search(request: Request, q: str):
return perform_search(q)
@app.post("/api/orders")
@limiter.limit("10/minute") # Transaction rate limiting — business logic
def create_order(request: Request, order: OrderRequest):
return process_order(order)
@app.get("/api/products")
@limiter.limit("100/minute") # Read endpoints — more permissive
def list_products(request: Request):
return get_products()
Rate Limiting Strategy
| Endpoint Type | Recommended Limit | Reason |
|---|---|---|
| Authentication (login, register, reset) | 5-10/minute per IP | Brute force prevention |
| Write operations (create, update, delete) | 10-30/minute per user | Abuse prevention |
| Search / query endpoints | 30-60/minute per user | Scraping prevention |
| Read operations (list, get) | 100-300/minute per user | Allow normal usage |
| File upload | 5/minute per user | Resource consumption control |
| Webhook endpoints | 100-500/minute per source | Burst handling |
Input Validation
Never trust client input. Validate every field with strict schemas and sanitize before processing.
from pydantic import BaseModel, validator, constr, conint
import re
class CreateUserRequest(BaseModel):
email: constr(max_length=254)
name: constr(min_length=1, max_length=100)
phone: constr(max_length=20) | None = None
age: conint(ge=13, le=150) | None = None # Reasonable bounds
@validator('email')
def validate_email(cls, v):
pattern = r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$'
if not re.match(pattern, v):
raise ValueError('Invalid email format')
return v.lower().strip()
@validator('name')
def sanitize_name(cls, v):
# Remove potential XSS characters
return re.sub(r'[<>"\'\/;]', '', v).strip()
class PaginationParams(BaseModel):
"""Prevent unbounded queries"""
page: conint(ge=1, le=10000) = 1
page_size: conint(ge=1, le=100) = 20 # Max 100 items per page
sort_by: str = "created_at"
@validator('sort_by')
def validate_sort_field(cls, v):
allowed = {"created_at", "updated_at", "name", "price"}
if v not in allowed:
raise ValueError(f'Sort field must be one of: {allowed}')
return v
Security Headers
from fastapi.middleware.cors import CORSMiddleware
# CORS — restrict to known origins only
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.garnetgrid.com"], # Never use "*" in production
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
allow_credentials=True,
)
# Security headers middleware
@app.middleware("http")
async def add_security_headers(request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Cache-Control"] = "no-store"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
return response
API Security Testing
Automated Scanning in CI/CD
# OWASP ZAP automated scan against OpenAPI spec
docker run -t owasp/zap2docker-stable zap-api-scan.py \
-t https://api.garnetgrid.com/openapi.json \
-f openapi \
-r security-report.html
# Nuclei — community-driven vulnerability scanner
nuclei -u https://api.garnetgrid.com \
-t http/vulnerabilities/ \
-severity critical,high
# Dredd — API contract testing against OpenAPI spec
dredd openapi.yaml https://api.garnetgrid.com
Manual Testing Checklist
| Test | How | What to Check |
|---|---|---|
| BOLA | Change resource IDs in requests | Access to other users’ resources |
| Auth bypass | Remove/modify Authorization header | Endpoint still returns data |
| Mass assignment | Add extra fields to POST/PUT body | Admin role, price, or status changes |
| SQL injection | Send ' OR 1=1 -- in parameters | Error messages, data leakage |
| Rate limit bypass | Send rapid requests from multiple IPs | Limits actually enforced |
| SSRF | Submit internal URLs in user-controlled fields | Access to cloud metadata (169.254.169.254) |
API Versioning for Security
Never deprecate without decommissioning. Old API versions are a common attack vector because they often lack the security improvements of newer versions.
| Practice | Implementation |
|---|---|
| Version in URL | /api/v1/users, /api/v2/users |
| Sunset header | Sunset: Sat, 01 Jan 2026 00:00:00 GMT |
| Deprecation notice | API response header: Deprecation: true |
| Force upgrade | Return 410 Gone after sunset date |
| Inventory | Maintain living API catalog (Swagger, Backstage) |
Checklist
- BOLA checks on every endpoint that accepts resource IDs (no exceptions)
- JWT tokens with short expiry (≤ 15 min) + secure refresh tokens
- Rate limiting tuned per endpoint type (auth, write, read, search)
- Input validation with strict schemas (Pydantic, Joi, Zod)
- CORS restricted to known origins (never wildcard in production)
- Security headers on all responses (HSTS, CSP, X-Frame-Options)
- API keys rotatable and scoped to minimum permissions
- Automated security scanning in CI/CD (ZAP, Nuclei, Trivy)
- Logging of all auth failures and suspicious activity
- API inventory maintained — no shadow or deprecated endpoints live
- Error messages sanitized (no stack traces, no internal paths)
:::note[Source] This guide is derived from operational intelligence at Garnet Grid Consulting. For API security consulting, visit garnetgrid.com. :::