3 min read
On this page

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.