API Security
APIs are the primary attack surface of modern applications. Every public endpoint is a door that must be locked, monitored, and defended. API security encompasses rate limiting, input validation, cross-origin protections, and authentication mechanisms that together prevent abuse, data theft, and service disruption.
Rate Limiting
Rate limiting controls how many requests a client can make within a time window. It protects against abuse, brute force attacks, and accidental overload.
Rate Limiting Algorithms
Fixed window:
100 requests per minute, window resets at the start of each minute.
Problem: burst of 100 at 0:59 and 100 at 1:00 = 200 in 2 seconds.
Sliding window log:
Track timestamp of every request, count within rolling window.
Accurate but memory-intensive (stores every timestamp).
Sliding window counter:
Weighted combination of current and previous window counts.
Current window: 70 requests (42 seconds into 60-second window)
Previous window: 90 requests
Weighted count: 90 * (18/60) + 70 = 97 out of 100 limit
Good balance of accuracy and memory efficiency.
Token bucket:
Bucket holds tokens (max capacity = burst limit).
Tokens added at a fixed rate (e.g., 10 per second).
Each request consumes one token.
Empty bucket = request rejected.
Allows controlled bursts while enforcing average rate.
Leaky bucket:
Requests enter a queue processed at a fixed rate.
Queue overflow = request rejected.
Smooths traffic, no bursts allowed.
Rate Limit Design Decisions
Rate limit dimensions:
Per IP address: protects against distributed attacks but breaks for shared IPs (NAT, corporate proxies)
Per API key: protects per customer, most common for authenticated APIs
Per user: protects per account, prevents credential stuffing
Per endpoint: different limits for different operations
GET /search: 100/minute (read, low cost)
POST /orders: 10/minute (write, high cost)
POST /auth/login: 5/minute (sensitive, brute force target)
Response when limited:
HTTP 429 Too Many Requests
Headers:
Retry-After: 30 (seconds until limit resets)
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1710505200 (epoch timestamp)
GitHub API enforces 5,000 requests per hour for authenticated users and 60 per hour for unauthenticated requests. Their response headers tell clients exactly how many requests remain and when the limit resets.
Distributed Rate Limiting
Challenge: multiple API servers must share rate limit state
Approaches:
Redis-based (most common):
Each server checks/increments a Redis counter
Atomic INCR + EXPIRE operations
Sub-millisecond latency
Local + sync:
Each server tracks local counts
Periodically syncs with central store
Less accurate but no per-request Redis call
Approximate (for very high scale):
Each server enforces local limit = global limit / server count
No coordination needed
Inaccurate when traffic is unevenly distributed
Stripe uses Redis-backed rate limiting with per-API-key tracking. Their system handles millions of API calls per minute while enforcing per-customer limits across multiple API server instances.
Input Validation
Every piece of data from outside the system boundary is untrusted. Input validation ensures that data conforms to expected formats and constraints before processing.
Validation Layers
Defense in depth for input validation:
Layer 1: API gateway / edge
Reject malformed requests (invalid JSON, oversized payloads)
Enforce content-type headers
Max request body size (e.g., 1MB)
Layer 2: Schema validation
Validate request against OpenAPI/JSON Schema
Required fields present, correct types, value constraints
Reject requests with unknown fields (strict mode)
Layer 3: Business logic validation
Domain-specific rules (amount > 0, valid date range)
Cross-field validation (end_date > start_date)
Authorization checks (user owns this resource)
Layer 4: Database constraints
NOT NULL, UNIQUE, CHECK constraints
Foreign key integrity
Last line of defense, should not be the only line
Common Input Attacks
SQL Injection:
Input: "'; DROP TABLE users; --"
Prevention: parameterized queries (never string concatenation)
Vulnerable: "SELECT * FROM users WHERE name = '" + input + "'"
Safe: "SELECT * FROM users WHERE name = $1" with input as parameter
NoSQL Injection:
Input: { "$gt": "" } (matches everything in MongoDB)
Prevention: validate types, reject operator-prefixed keys
Command Injection:
Input: "file.txt; rm -rf /"
Prevention: never pass user input to shell commands
Use library functions instead of shell execution
Path Traversal:
Input: "../../etc/passwd"
Prevention: validate and canonicalize file paths
Reject paths containing ".." or starting with "/"
XML External Entity (XXE):
Input: XML with external entity references loading system files
Prevention: disable external entity processing in XML parsers
Payload Size Limits
Size limit guidelines:
Request body: 1MB default, higher for file uploads
URL length: 2,048 characters (practical browser limit)
Header size: 8KB total (nginx default)
Array/list fields: maximum number of items (e.g., max 100 IDs per batch)
String fields: maximum length per field
Nested depth: maximum JSON nesting (e.g., 10 levels)
Oversized payloads can cause:
Memory exhaustion (load entire body into memory)
CPU exhaustion (parsing deeply nested structures)
Storage exhaustion (saving to database)
Cross-Origin Resource Sharing (CORS)
CORS controls which web origins can make requests to your API from a browser. It prevents malicious websites from making authenticated requests to your API using a user's cookies.
How CORS Works
Same-origin policy (browser default):
JavaScript on evil.com CANNOT make requests to your-api.com
Browser blocks the request before it is sent
CORS relaxes this:
Your API responds with Access-Control-Allow-Origin: app.yoursite.com
Browser allows JavaScript on app.yoursite.com to call your API
Browser still blocks requests from evil.com
Preflight request (for non-simple requests):
Browser sends OPTIONS request first:
Origin: app.yoursite.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
Server responds:
Access-Control-Allow-Origin: app.yoursite.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400 (cache preflight for 24 hours)
Browser sends actual POST request only if preflight succeeds
CORS Configuration
CORS best practices:
- Never use Access-Control-Allow-Origin: * for APIs with authentication
- Whitelist specific origins: ["https://app.yoursite.com", "https://admin.yoursite.com"]
- Validate Origin header against whitelist on every request
- Expose only necessary response headers with Access-Control-Expose-Headers
- Set Access-Control-Max-Age to reduce preflight frequency
- Restrict allowed methods to what your API actually supports
Cross-Site Request Forgery (CSRF)
CSRF tricks a user's browser into making unintended requests to your API using the user's existing authentication cookies.
CSRF Attack Flow
CSRF attack:
1. User is logged into bank.com (has session cookie)
2. User visits evil.com
3. evil.com contains: <form action="https://bank.com/transfer" method="POST">
<input name="to" value="attacker">
<input name="amount" value="10000">
4. JavaScript auto-submits the form
5. Browser includes bank.com session cookie (automatic)
6. bank.com processes the transfer because the cookie is valid
The user never intended to make this transfer.
CSRF Prevention
Prevention strategies:
Synchronizer token pattern:
Server generates random CSRF token, stores in session
Token included in every form as hidden field
Server validates token on every POST/PUT/DELETE
Attacker cannot guess the token from another origin
SameSite cookies:
Set-Cookie: session=abc; SameSite=Strict
Strict: cookie never sent on cross-origin requests
Lax: cookie sent on top-level navigations (GET) but not form POSTs
Eliminates CSRF for most cases without additional tokens
Custom request headers:
Require X-Requested-With: XMLHttpRequest on all API calls
Browsers add this for AJAX but not for form submissions
Cross-origin requests cannot set custom headers without CORS approval
Double submit cookie:
Set CSRF token as cookie AND require it in request body/header
Attacker can trigger cookie inclusion but cannot read cookie value
to include in the request body
Injection Prevention
Injection attacks exploit the boundary between data and code. They occur whenever user input is interpreted as executable instructions.
Prevention Principles
Universal injection prevention:
1. Parameterized queries for all database access
Never construct SQL/NoSQL queries with string concatenation
2. Output encoding for all rendered content
HTML-encode user data before including in web pages (XSS prevention)
3. Command avoidance
Use language APIs instead of shell commands
If shell is required, use allowlists for permitted values
4. Type checking
Validate that inputs match expected types before processing
A userId should be a UUID, not an arbitrary string
5. Least privilege for database accounts
API's database user should only have SELECT/INSERT/UPDATE on needed tables
Not DROP TABLE or admin privileges
Content Security Policy (CSP)
CSP prevents XSS by controlling what resources a page can load:
Content-Security-Policy:
default-src 'self';
script-src 'self' https://cdn.trusted.com;
style-src 'self' 'unsafe-inline';
img-src *;
connect-src 'self' https://api.yoursite.com;
This policy:
- Only loads scripts from your domain and the trusted CDN
- Blocks inline scripts (where most XSS payloads execute)
- Allows images from any source
- Only allows API calls to your domain
GitHub uses strict CSP headers to prevent XSS attacks on their web application. Their policy blocks inline scripts and limits resource loading to approved domains.
API Keys vs Tokens
API keys and tokens serve different purposes in API authentication.
API Keys
API keys:
- Long-lived, static credentials
- Identify the calling application (not the user)
- Typically sent as header: X-API-Key: abc123
- Used for: rate limiting per customer, usage tracking, billing
Limitations:
- No user context (who is using the application)
- Hard to rotate (every client must update)
- If leaked, valid until manually revoked
- Not suitable as sole authentication for sensitive operations
Bearer Tokens (OAuth)
Bearer tokens:
- Short-lived, dynamically issued
- Identify the user AND the application
- Sent as header: Authorization: Bearer eyJhbGci...
- Used for: user-specific actions, delegated access
Advantages:
- Scoped permissions (read-only, specific resources)
- Short expiration limits exposure
- Revocable through token revocation endpoint
- Standard (OAuth 2.0, OIDC)
When to Use Each
| Scenario | API Key | OAuth Token |
|-----------------------------------|---------|-------------|
| Server-to-server, no user context | Yes | Client cred |
| User-facing actions | No | Yes |
| Public API usage tracking | Yes | Optional |
| Third-party app accessing user data| No | Yes |
| Webhook verification | Yes | No |
| Mobile app authentication | No | Yes (PKCE) |
Best practice: use API keys for identification/billing AND
OAuth tokens for authorization. Many APIs require both.
Twilio uses API keys (Account SID + Auth Token) for server-to-server calls where no end-user is involved. Slack uses OAuth tokens for actions on behalf of users (posting messages, accessing channels) where user identity and permissions matter.
Common Pitfalls
- Rate limiting only at the application level. Without edge-level rate limiting (CDN, API gateway), volumetric attacks reach your application servers. Implement rate limiting at multiple layers.
- Trusting client-side validation. Client-side validation improves UX but provides zero security. Server-side validation is the only validation that counts.
- CORS wildcard with credentials. Setting
Access-Control-Allow-Origin: *while allowing credentials is explicitly forbidden by browsers, but misconfigurations with origin reflection can have the same effect. - Logging sensitive request data. Request bodies may contain passwords, credit cards, or personal data. Scrub sensitive fields before logging.
- API keys in URLs. Keys in query strings appear in browser history, server logs, and referrer headers. Always send keys in headers or request bodies.
- Missing rate limits on authentication endpoints. Login and password reset endpoints without rate limits enable brute force and credential stuffing attacks.
Key Takeaways
- Rate limiting is essential for every public API. Use token bucket for flexibility, enforce at both edge and application layers, and differentiate limits by endpoint sensitivity.
- Input validation must happen server-side at multiple layers: schema validation, business logic, and database constraints. Never trust client input.
- CORS and CSRF protections work together. CORS controls which origins can call your API. CSRF tokens prevent forged requests from authenticated browsers. SameSite cookies eliminate most CSRF attacks.
- Use parameterized queries to prevent injection. No exceptions, no shortcuts. String concatenation in queries is the most preventable vulnerability class.
- API keys identify applications. OAuth tokens authenticate users. Most systems need both for complete API security.