Secure API Design#
Every API exposed to any network — public or internal — is an attack surface. The difference between a secure API and a vulnerable one is not exotic cryptography. It is consistent application of known patterns: authenticate every request, authorize every action, validate every input, and limit every resource.
Authentication Schemes#
API Keys#
The simplest scheme. The client sends a static key in a header:
GET /api/v1/data HTTP/1.1
Host: api.example.com
X-API-Key: sk_live_abc123def456API keys are appropriate for:
- Server-to-server communication where both sides are trusted.
- Rate limiting and usage tracking per client.
- Low-sensitivity endpoints where the key is the only required credential.
API keys are not appropriate for:
- User-facing authentication (no concept of user identity or sessions).
- Sensitive operations (keys are long-lived, hard to scope, and easy to leak).
Implementation rules:
- Always transmit keys over TLS.
- Store hashed keys server-side (SHA-256 minimum). Never store plaintext keys.
- Support key rotation — allow multiple active keys per client with expiry dates.
- Prefix keys with a type indicator (
sk_live_,sk_test_) so leaked keys can be identified and revoked.
JWT (JSON Web Tokens)#
JWTs are signed tokens containing claims. The server verifies the signature without a database lookup:
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTEyMyIsInNjb3BlIjoicmVhZCB3cml0ZSIsImlhdCI6MTcwOTI0ODAwMCwiZXhwIjoxNzA5MjUxNjAwfQ.signatureA JWT contains three parts (header.payload.signature):
// Header
{"alg": "RS256", "typ": "JWT", "kid": "key-2026-01"}
// Payload
{
"sub": "user-123",
"scope": "read write",
"iat": 1709248000,
"exp": 1709251600,
"iss": "https://auth.example.com"
}JWT security rules:
- Always use asymmetric signing (RS256 or ES256). Symmetric signing (HS256) means the same secret verifies and creates tokens — if any service can verify, it can also forge tokens.
- Always validate
exp,iss, andaudclaims. An expired token from the wrong issuer for the wrong audience should be rejected. - Short expiry times. Access tokens should expire in 15-60 minutes. Use refresh tokens for longer sessions.
- Never put sensitive data in the payload. JWTs are signed, not encrypted. Anyone can decode the payload.
- Use
kid(key ID) in the header. This allows key rotation without breaking existing tokens.
// Validation example (Go)
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Verify algorithm to prevent algorithm confusion attacks
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
kid := token.Header["kid"].(string)
return getPublicKey(kid)
})
if err != nil || !token.Valid {
return ErrUnauthorized
}
claims := token.Claims.(jwt.MapClaims)
if !claims.VerifyIssuer("https://auth.example.com", true) {
return ErrUnauthorized
}
if !claims.VerifyAudience("https://api.example.com", true) {
return ErrUnauthorized
}OAuth2 and OIDC#
OAuth2 is a framework for delegated authorization. OpenID Connect (OIDC) adds an identity layer on top. Together, they handle the common case of “user logs in via identity provider, gets a token, uses token to call API.”
The most important flows:
Authorization Code Flow (with PKCE) — For browser-based and mobile apps:
User → App → Redirect to Identity Provider (login page)
→ User authenticates
→ IDP redirects back to app with authorization code
→ App exchanges code for access token (server-side)
→ App uses access token to call APIPKCE (Proof Key for Code Exchange) prevents authorization code interception. Always use PKCE, even for server-side apps.
Client Credentials Flow — For service-to-service:
Service → POST /token with client_id + client_secret
→ IDP returns access token
→ Service uses access token to call APINo user involved. The client authenticates directly with its credentials.
Implementation rules:
- Never implement your own OAuth2 server unless you deeply understand the spec. Use Keycloak, Auth0, Okta, or cloud-native equivalents.
- Always validate the
audclaim to prevent token confusion attacks (token issued for Service A used against Service B). - Store refresh tokens securely (encrypted, server-side). Rotate refresh tokens on every use.
- Implement token revocation for logout and compromised tokens.
Authorization Patterns#
Authentication answers “who are you?” Authorization answers “what can you do?”
Role-Based Access Control (RBAC)#
Users are assigned roles. Roles have permissions. Check the role on every request:
PERMISSIONS = {
"admin": ["read", "write", "delete", "manage_users"],
"editor": ["read", "write"],
"viewer": ["read"],
}
def authorize(user_role: str, required_permission: str) -> bool:
return required_permission in PERMISSIONS.get(user_role, [])RBAC is simple and works well when permissions map cleanly to roles. It breaks down when you need fine-grained access (user A can edit their own posts but not user B’s).
Attribute-Based Access Control (ABAC)#
Decisions based on attributes of the user, resource, and context:
def can_edit_document(user, document):
if user.role == "admin":
return True
if document.owner_id == user.id:
return True
if user.department == document.department and "editor" in user.roles:
return True
return FalseMore flexible than RBAC but harder to audit and reason about. Use when RBAC is too coarse.
Broken Object-Level Authorization (BOLA)#
The number one API vulnerability (OWASP API #1). A user requests a resource by ID and gets it, even if it belongs to someone else:
GET /api/v1/users/456/documents/789
# User 123 can access user 456's documents by changing the IDFix: always verify the authenticated user has access to the specific resource:
@app.get("/api/v1/documents/{doc_id}")
def get_document(doc_id: str, current_user: User = Depends(get_current_user)):
doc = db.get_document(doc_id)
if doc is None:
raise HTTPException(404)
if doc.owner_id != current_user.id and not current_user.is_admin:
raise HTTPException(403)
return docNever rely on the client sending the correct user ID. Always derive the user identity from the authenticated token.
Input Validation#
Every input is hostile until validated. This applies to path parameters, query parameters, headers, and request bodies.
Validation Strategy#
Request arrives
→ Schema validation (structure, types, required fields)
→ Business rule validation (ranges, formats, relationships)
→ Sanitization (encoding, escaping for output context)
→ Process requestSchema Validation with OpenAPI#
Define your API schema and validate against it:
paths:
/api/v1/users:
post:
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email, name]
properties:
email:
type: string
format: email
maxLength: 254
name:
type: string
minLength: 1
maxLength: 100
pattern: "^[a-zA-Z0-9 .'-]+$"
age:
type: integer
minimum: 0
maximum: 150
additionalProperties: falseadditionalProperties: false rejects any fields not in the schema. Without it, clients can send arbitrary fields that might be processed by downstream code.
Injection Prevention#
- SQL injection: Use parameterized queries. Never concatenate user input into SQL strings.
# WRONG
cursor.execute(f"SELECT * FROM users WHERE id = '{user_id}'")
# RIGHT
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))- Command injection: Never pass user input to shell commands. Use libraries that take arguments as arrays.
# WRONG
os.system(f"convert {filename} output.png")
# RIGHT
subprocess.run(["convert", filename, "output.png"], check=True)- NoSQL injection: MongoDB and similar databases are vulnerable to operator injection:
# WRONG: user_input could be {"$gt": ""} which matches everything
db.users.find({"username": user_input})
# RIGHT: explicitly cast to string
db.users.find({"username": str(user_input)})Request Size Limits#
Set maximum request body sizes to prevent memory exhaustion:
# FastAPI
app = FastAPI()
app.add_middleware(
TrustedHostMiddleware, allowed_hosts=["api.example.com"]
)
# Nginx
client_max_body_size 1m;
# Envoy
http_connection_manager:
max_request_headers_kb: 60Rate Limiting#
Rate limiting prevents abuse, protects backend resources, and ensures fair usage.
Token Bucket Algorithm#
The most common approach. Each client has a bucket that fills with tokens at a fixed rate. Each request consumes a token. When the bucket is empty, requests are rejected.
Bucket capacity: 100 tokens
Refill rate: 10 tokens/second
Request arrives:
- If bucket has tokens: allow, remove 1 token
- If bucket is empty: reject with 429 Too Many RequestsRate Limit Headers#
Return rate limit information so clients can self-regulate:
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1709248060
Retry-After: 30 (only on 429 responses)Tiered Rate Limits#
Different limits for different contexts:
rate_limits:
# Global: protect the infrastructure
- key: global
rate: 10000/minute
# Per-IP: prevent single-source abuse
- key: ip
rate: 100/minute
# Per-API-key: enforce plan limits
- key: api_key
rate:
free: 60/minute
pro: 600/minute
enterprise: 6000/minute
# Per-endpoint: protect expensive operations
- key: endpoint
endpoints:
/api/v1/search: 20/minute
/api/v1/export: 5/minuteWhere to Rate Limit#
Rate limit at the edge (API gateway, load balancer), not in the application. By the time a request reaches your application code, it has already consumed resources. Nginx, Envoy, Cloudflare, and AWS API Gateway all support rate limiting natively.
OWASP API Security Top 10#
The OWASP API Security Top 10 catalogs the most critical API vulnerabilities. Here is how each applies and how to defend against it:
API1 — Broken Object-Level Authorization (BOLA). Covered above. Always verify the authenticated user has access to the specific resource they are requesting. Do not trust client-supplied IDs.
API2 — Broken Authentication. Weak authentication mechanisms: no rate limiting on login, credentials in URLs, weak password policies, missing token validation. Fix: use proven auth libraries, rate limit auth endpoints aggressively, never accept tokens without full validation.
API3 — Broken Object Property-Level Authorization. The API returns more data than the user should see, or accepts writes to fields the user should not modify. Fix: explicitly define response schemas per role. Never return the raw database object.
# WRONG: returns all fields including is_admin
return user.dict()
# RIGHT: return only allowed fields
return {"id": user.id, "name": user.name, "email": user.email}API4 — Unrestricted Resource Consumption. No limits on request size, pagination, or computation. An attacker requests 10 million records or triggers an expensive operation repeatedly. Fix: enforce pagination limits, request size limits, query complexity limits, and rate limiting.
API5 — Broken Function-Level Authorization. Admin endpoints accessible to regular users. Fix: enforce authorization checks on every endpoint, not just data access. Test that non-admin users get 403 on admin routes.
API6 — Unrestricted Access to Sensitive Business Flows. Automated abuse of legitimate functionality: mass account creation, inventory hoarding, scraping. Fix: rate limiting, CAPTCHA for sensitive flows, behavioral detection.
API7 — Server-Side Request Forgery (SSRF). The API fetches a URL provided by the user and the attacker points it at internal services. Fix: validate and allowlist URLs, block internal IP ranges, use a dedicated egress proxy.
import ipaddress
def is_safe_url(url: str) -> bool:
parsed = urlparse(url)
try:
ip = ipaddress.ip_address(socket.gethostbyname(parsed.hostname))
except (socket.gaierror, ValueError):
return False
return ip.is_global # Reject private, loopback, link-localAPI8 — Security Misconfiguration. Default credentials, verbose error messages, unnecessary HTTP methods enabled, missing security headers. Fix: harden defaults, strip stack traces from production errors, disable unused HTTP methods, add security headers.
API9 — Improper Inventory Management. Forgotten API versions, undocumented endpoints, test environments exposed to the internet. Fix: maintain an API inventory, decommission old versions, scan for exposed endpoints.
API10 — Unsafe Consumption of APIs. Your API trusts data from third-party APIs without validation. The third party is compromised or returns malicious data. Fix: validate all external API responses the same way you validate user input.
Security Headers#
Every API response should include:
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Cache-Control: no-store
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'For APIs that never serve HTML, Content-Security-Policy: default-src 'none' prevents any injected content from executing.
Common Mistakes#
- Trusting client-supplied user IDs instead of deriving identity from the token. The token says who the user is. The path parameter says which resource they want. Never trust the path to determine identity.
- Returning raw database objects in API responses. Internal fields, password hashes, admin flags, and foreign keys leak to clients. Always map to explicit response schemas.
- Rate limiting only by IP. Shared IPs (corporate NAT, VPNs, cloud functions) penalize legitimate users. Rate limit by API key or authenticated identity as the primary key, with IP as a fallback for unauthenticated endpoints.
- Logging sensitive data. Request logs that include authorization headers, API keys, or request bodies with passwords create a second exposure surface. Redact sensitive fields before logging.
- Relying on API gateway auth without defense in depth. If the gateway is misconfigured or bypassed, the application is unprotected. Validate auth at both the gateway and the application layer.