API Design: API Security
OWASP API Security Top 10 identifies the most critical API vulnerabilities. Understanding these is essential for designing safe APIs.
OWASP API Security Top 10
API1 — Broken Object Level Authorization (BOLA/IDOR): always verify the current user owns the resource. Never trust client-provided IDs without checking ownership. GET /orders/123 must verify order 123 belongs to the authenticated user.
API2 — Broken Authentication: use short-lived tokens (15min access, 7d refresh), rotate refresh tokens, rate-limit login attempts, use secure cookie attributes (HttpOnly, Secure, SameSite=Strict).
API3 — Broken Object Property Level Authorization: never use mass assignment. Whitelist which fields clients can set. PATCH /users/:id must not allow setting role or isAdmin.
API4 — Unrestricted Resource Consumption: rate-limit all endpoints, limit request body size, paginate all lists, add query complexity limits in GraphQL, add timeouts to all DB queries.
API5 — Broken Function Level Authorization: admin endpoints must check role, not just authentication. Test every privileged endpoint with a non-admin token.
API6 — Unrestricted Access to Sensitive Business Flows: limit password resets, OTP requests, payment attempts per user per time window. Detect and block automated flows.
API7 — Server Side Request Forgery (SSRF): validate and allowlist URLs when your API fetches external resources. Never pass raw user-supplied URLs to HTTP clients.
API8 — Security Misconfiguration: disable debug endpoints in production, remove CORS wildcard (*) for authenticated APIs, set security headers, disable unnecessary HTTP methods.
API9 — Improper Inventory Management: document all APIs, decommission old versions, don't expose beta/internal APIs without auth, monitor for shadow APIs.
API10 — Unsafe Consumption of APIs: validate and sanitize all data from third-party APIs before using it. Third parties can be compromised.
CORS
# CORS (Cross-Origin Resource Sharing) — browser enforces, server configures
# Simple request: browser sends directly, server must respond with headers
# Preflight: browser sends OPTIONS first for non-simple requests
# Response headers:
Access-Control-Allow-Origin: https://app.example.com # ✅ specific origin
Access-Control-Allow-Origin: * # ❌ never for authenticated APIs
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true # required for cookies/auth headers
Access-Control-Max-Age: 86400 # cache preflight for 24h
# Common mistakes:
# - Reflecting back any Origin header without validation (open CORS)
# - Combining * with Allow-Credentials (browsers reject this)
# - Not handling OPTIONS preflight (returns 404/405)Authentication & Token Security
# JWT storage — where to keep tokens in the browser
LocalStorage / SessionStorage:
✅ Easy to use, accessible from JS
❌ XSS vulnerable — any injected script can read it
❌ Never store long-lived tokens here
HttpOnly Cookie:
✅ Inaccessible to JS — XSS cannot steal it
✅ Automatically sent with requests
❌ CSRF vulnerable — add CSRF token or use SameSite=Strict
Recommended: HttpOnly Secure SameSite=Strict cookie for refresh tokens
Bearer header (short-lived access token) from memory (not localStorage)
# Security headers every API should set:
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Content-Security-Policy: default-src 'none'
Referrer-Policy: strict-origin-when-cross-originRate Limiting Strategies
# Fixed Window — count requests in a fixed time window
Limit: 100 requests / 60 seconds
Problem: burst at window boundary (200 requests in 2 seconds straddling the window)
# Sliding Window — rolling window, no boundary burst
More accurate, more memory usage (store per-user timestamps)
# Token Bucket — bucket refills at a rate, burst allowed up to bucket size
bucket_size: 20, refill_rate: 10/second
Allows short bursts (20 at once), then throttles to 10/s sustained
# Leaky Bucket — requests process at fixed rate, excess queued or dropped
Smooth output rate, good for downstream protection
# Implementation tiers:
Per-IP: coarse-grained, spoofable (X-Forwarded-For)
Per-user: after auth, accurate for logged-in users
Per-API-key: for server-to-server, track per key
Per-endpoint: tighter limits on expensive endpoints (search, export)Input Validation & Injection Prevention
# SQL Injection — NEVER concatenate user input into SQL
❌ db.query("SELECT * FROM users WHERE name = '" + name + "'")
✅ db.query("SELECT * FROM users WHERE name = $1", [name]) // parameterized
# NoSQL Injection (MongoDB)
❌ db.users.find({ username: req.body.username })
// attacker sends: { "$gt": "" } — returns all users
✅ Validate input type before passing to query
✅ Use schema validation (Zod, Joi) at API boundary
# Command Injection
❌ exec(`convert ${filename} output.jpg`)
✅ execFile('convert', [filename, 'output.jpg']) // no shell interpolation
# Path Traversal
❌ readFile('./uploads/' + req.params.file)
✅ const safe = path.resolve('./uploads', req.params.file)
if (!safe.startsWith(path.resolve('./uploads'))) throw new Error('Invalid path')
# General input validation rules:
- Validate at the API boundary (before any processing)
- Reject unknown fields (strict mode in Zod/class-validator)
- Limit string lengths and array sizes
- Validate content types match declared Content-Type headerKeep your own version of these notes — editable, searchable, and organised by your stack.
Start free