Why API Security Is Different#

APIs are the primary attack surface of modern applications. Unlike server-rendered pages, APIs expose raw business logic directly. They are consumed by browsers, mobile apps, third-party integrations, and automated scripts — each with different trust levels.

The OWASP API Security Top 10 (2023) highlights risks specific to APIs that the standard Top 10 does not adequately cover.


Input Validation#

Every API endpoint must validate every input. Never trust the client — not the browser, not the mobile app, not a trusted partner’s integration.

Validation Strategy#

LayerWhat to ValidateExample
SchemaTypes, required fields, formatsJSON Schema, Zod, Pydantic
Business rulesRanges, relationships, invariantsquantity > 0, end_date > start_date
Size limitsPayload size, string length, array sizeContent-Length < 1MB, name.length <= 100

Implementation Examples#

Python (Pydantic):

from pydantic import BaseModel, Field, field_validator
from datetime import date

class CreateBookingRequest(BaseModel):
    destination: str = Field(min_length=1, max_length=200)
    passengers: int = Field(ge=1, le=50)
    start_date: date
    end_date: date

    @field_validator('end_date')
    @classmethod
    def end_after_start(cls, v, info):
        if 'start_date' in info.data and v <= info.data['start_date']:
            raise ValueError('end_date must be after start_date')
        return v

Java (Jakarta Validation):

public record CreateBookingRequest(
    @NotBlank @Size(max = 200) String destination,
    @Min(1) @Max(50) int passengers,
    @NotNull @Future LocalDate startDate,
    @NotNull @Future LocalDate endDate
) {}

Node.js (Zod):

import { z } from 'zod';

const CreateBookingSchema = z.object({
  destination: z.string().min(1).max(200),
  passengers: z.number().int().min(1).max(50),
  startDate: z.string().date(),
  endDate: z.string().date(),
}).refine(
  (data) => new Date(data.endDate) > new Date(data.startDate),
  { message: 'endDate must be after startDate' }
);

Rate Limiting#

Rate limiting prevents abuse, brute force, and denial of service. Apply different limits based on the endpoint’s sensitivity.

Common Strategies#

StrategyHow It WorksBest For
Fixed windowN requests per time windowSimple, general use
Sliding windowSmooth rate across window boundariesMore accurate limiting
Token bucketTokens replenish at fixed rate; burst allowedAPIs with occasional bursts
Leaky bucketRequests processed at constant rate; excess queuedConsistent throughput

Tiered Limits#

Endpoint CategoryLimitReasoning
Login / auth5/min per IP + 10/min per accountBrute force protection
Password reset3/hour per emailPrevent abuse
Search30/min per userExpensive queries
Standard API100/min per userNormal usage
Webhook receivers1000/min per sourceHigh volume, trusted

Response Headers (RFC 6585 / draft-ietf-httpapi-ratelimit-headers)#

HTTP/1.1 429 Too Many Requests
Retry-After: 30
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 1724770000
Content-Type: application/problem+json

{
  "type": "https://api.example.com/errors/rate-limit",
  "title": "Rate limit exceeded",
  "status": 429,
  "detail": "You have exceeded 100 requests per minute. Retry after 30 seconds."
}

Implementation (Express.js)#

import rateLimit from 'express-rate-limit';

const apiLimiter = rateLimit({
  windowMs: 60 * 1000,    // 1 minute
  max: 100,                // 100 requests per window
  standardHeaders: true,   // RateLimit-* headers
  legacyHeaders: false,
  keyGenerator: (req) => req.user?.id || req.ip,
  handler: (req, res) => {
    res.status(429).json({
      type: 'https://api.example.com/errors/rate-limit',
      title: 'Rate limit exceeded',
      status: 429,
    });
  },
});

app.use('/api/', apiLimiter);

API Keys vs OAuth#

AspectAPI KeyOAuth 2.0 Token
RepresentsAn applicationA user (or application for client_credentials)
GranularityCoarse (entire app)Fine (scoped per user, per permission)
RevocationRotate the key (all users affected)Revoke individual tokens
User context❌ No user identity✅ User identity in token claims
ExpirationUsually long-livedShort-lived (minutes to hours)
Best forServer-to-server, rate limiting, analyticsUser-facing APIs, delegated access

API keys are not authentication — they identify the calling application but not the user. For user-facing APIs, use OAuth 2.0.


CORS (Cross-Origin Resource Sharing)#

CORS controls which origins can make requests to your API from a browser. Without CORS headers, browsers block cross-origin AJAX requests.

How CORS Works#

Browser (https://app.com)              API (https://api.example.com)
  │                                        │
  │── Preflight (OPTIONS) ────────────────▶│
  │   Origin: https://app.com              │
  │   Access-Control-Request-Method: POST  │
  │                                        │
  │◀── Preflight Response ────────────────│
  │    Access-Control-Allow-Origin: https://app.com
  │    Access-Control-Allow-Methods: GET, POST
  │    Access-Control-Allow-Headers: Content-Type, Authorization
  │    Access-Control-Max-Age: 86400       │
  │                                        │
  │── Actual Request (POST) ──────────────▶│
  │   Origin: https://app.com              │
  │                                        │
  │◀── Response ──────────────────────────│
  │    Access-Control-Allow-Origin: https://app.com

CORS Configuration#

// Express.js
import cors from 'cors';

app.use(cors({
  origin: ['https://app.example.com', 'https://admin.example.com'],
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,        // Allow cookies
  maxAge: 86400,            // Cache preflight for 24 hours
}));

CORS Mistakes#

MistakeRisk
Access-Control-Allow-Origin: * with credentials: trueBrowsers block this — but trying it reveals misunderstanding
Reflecting Origin header without validationAny site can make authenticated requests
Allowing null originfile:// pages and sandboxed iframes match null
Overly broad allowlistSubdomain takeover on *.example.com compromises the API

Safe pattern — validate against an explicit allowlist:

const ALLOWED_ORIGINS = new Set([
  'https://app.example.com',
  'https://admin.example.com',
]);

app.use(cors({
  origin: (origin, callback) => {
    if (!origin || ALLOWED_ORIGINS.has(origin)) {
      callback(null, true);
    } else {
      callback(new Error('CORS not allowed'));
    }
  },
}));

GraphQL Security#

GraphQL introduces unique security concerns because clients control query structure.

Key Risks#

RiskDescriptionMitigation
Deeply nested queriesQuery depth causes exponential DB queriesQuery depth limiting (max 10–15 levels)
Expensive queriesSingle request fetches massive dataQuery complexity analysis, cost limits
IntrospectionSchema exposed to attackersDisable in production
Batching attacksMultiple mutations in one requestLimit batch size, rate limit per operation
InjectionArguments passed to resolvers unsafelyParameterised queries in resolvers

Query Depth and Complexity Limits#

// Apollo Server with depth limiting
import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    depthLimit(10),
    createComplexityLimitRule(1000),
  ],
  introspection: process.env.NODE_ENV !== 'production',
});

Disable Introspection in Production#

# Attacker's introspection query — should be blocked
{
  __schema {
    types {
      name
      fields { name type { name } }
    }
  }
}

Webhook Verification#

When receiving webhooks from external services, verify the request is authentic:

HMAC Signature Verification#

import hmac
import hashlib

def verify_webhook(payload: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode(),
        payload,
        hashlib.sha256,
    ).hexdigest()

    # Constant-time comparison to prevent timing attacks
    return hmac.compare_digest(f"sha256={expected}", signature)

# In your handler:
@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
    body = await request.body()
    signature = request.headers.get("Stripe-Signature", "")

    if not verify_webhook(body, signature, STRIPE_WEBHOOK_SECRET):
        raise HTTPException(status_code=401, detail="Invalid signature")

    event = json.loads(body)
    # Process the event

Webhook Security Checklist#

✅Control
☐Verify HMAC signature on every request
☐Use constant-time comparison (hmac.compare_digest)
☐Reject requests with missing or invalid signatures
☐Store webhook secrets in secrets manager
☐Validate event freshness (reject events older than N minutes)
☐Return 2xx quickly, process asynchronously
☐Idempotent processing (handle duplicate deliveries)

Key Takeaways#

  • Validate all input with schema validation libraries (Pydantic, Zod, Jakarta Validation) — type checks, range limits, and business rules on every endpoint.
  • Rate limit by endpoint sensitivity — authentication endpoints need strict per-IP and per-account limits; standard endpoints need per-user limits.
  • API keys identify applications, not users — use OAuth 2.0 tokens for user-facing APIs with scoped permissions.
  • CORS must use an explicit origin allowlist — never reflect the Origin header without validation, and never use * with credentials.
  • GraphQL requires additional controls — depth limiting, complexity analysis, and disabled introspection in production.
  • Verify webhook signatures using HMAC with constant-time comparison — never trust the payload without cryptographic proof of authenticity.