API Security Best Practices Cheat Sheet
Authentication, authorization, input validation, and rate-limiting patterns to protect REST and GraphQL APIs in production.
Verify a JWT Before Trusting Claims
Always verify signature, issuer, audience, and expiry server-side.
import { jwtVerify, createRemoteJWKSet } from "jose";const JWKS = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json"));async function verifyToken(token) { const { payload } = await jwtVerify(token, JWKS, { issuer: "https://auth.example.com", audience: "api.example.com", }); return payload; // never trust claims from an unverified token}
Rate Limiting Middleware (Express)
Per-IP and per-key limits to blunt brute-force and scraping.
import rateLimit from "express-rate-limit";const apiLimiter = rateLimit({ windowMs: 60 * 1000, // 1 minute max: 100, // 100 requests per window per key standardHeaders: true, legacyHeaders: false, keyGenerator: (req) => req.headers["x-api-key"] || req.ip,});app.use("/api/", apiLimiter);
Schema-Based Input Validation
Reject malformed/oversized payloads before they reach business logic.
import { z } from "zod";const CreateOrderSchema = z.object({ sku: z.string().max(64), quantity: z.number().int().positive().max(1000), notes: z.string().max(500).optional(),});app.post("/orders", (req, res) => { const result = CreateOrderSchema.safeParse(req.body); if (!result.success) { return res.status(400).json({ error: result.error.flatten() }); } // result.data is now typed and validated});
API Security Checklist
Baseline controls every production API should have.
- TLS everywhere- reject plaintext HTTP, use HSTS on public endpoints
- Least-privilege scopes- OAuth scopes/claims per endpoint, not one god-token
- Object-level authz- verify the caller owns/can access the specific record ID (BOLA/IDOR)
- Output filtering- never serialize internal-only fields (password hashes, internal IDs)
- CORS allowlist- explicit origins, not `*`, especially with credentials
- Security headers- CSP, X-Content-Type-Options, X-Frame-Options on any HTML responses
- Audit logging- log auth failures and sensitive actions with request context
Verify Inbound Webhook Signatures
Confirm a webhook payload actually came from the claimed sender using an HMAC signature.
import crypto from "crypto";function verifyWebhookSignature(rawBody, signatureHeader, secret) { const expected = crypto .createHmac("sha256", secret) .update(rawBody) .digest("hex"); const provided = Buffer.from(signatureHeader, "hex"); const computed = Buffer.from(expected, "hex"); // constant-time comparison - prevents leaking match length via timing if (provided.length !== computed.length || !crypto.timingSafeEqual(provided, computed)) { throw new Error("Invalid webhook signature"); }}
Replay Attack Prevention
Reject requests outside a freshness window or that reuse a previously-seen nonce.
const MAX_SKEW_MS = 5 * 60 * 1000;const seenNonces = new Set(); // back with Redis + TTL in productionfunction verifyRequestFreshness(timestamp, nonce) { const skew = Math.abs(Date.now() - Number(timestamp)); if (skew > MAX_SKEW_MS) throw new Error("Timestamp outside allowed window"); if (seenNonces.has(nonce)) throw new Error("Nonce already used - possible replay"); seenNonces.add(nonce);}
GraphQL Query Depth & Complexity Limits
Bound query cost so a single request can't fan out into an expensive resource-exhaustion attack.
import depthLimit from "graphql-depth-limit";import { createComplexityLimitRule } from "graphql-validation-complexity";const server = new ApolloServer({ schema, validationRules: [ depthLimit(6), createComplexityLimitRule(1000, { onCost: (cost) => logger.info("query cost", { cost }), }), ],});
OAuth2 Token Introspection
Check revocation/active status for opaque or high-privilege tokens the RS can't verify statelessly.
curl -s https://auth.example.com/oauth/introspect \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -d "token=$ACCESS_TOKEN" \ -d "token_type_hint=access_token" \ | jq '.active, .scope, .exp'# active:false means the token is dead even if its JWT signature still verifies -# introspection is the only way to catch server-side revocation before expiry
OWASP API Top 10 - Beyond Auth & Rate Limits
Risk classes that survive JWT verification and schema validation because the request is syntactically valid.
- Mass assignment- client sends extra fields (role, isAdmin) that get bound straight to a model
- Excessive data exposure- API returns full internal objects and lets the client filter, not the server
- Unrestricted resource consumption- no caps on pagination size, file upload size, or batch operation count
- Security misconfiguration- verbose stack traces, default creds, or debug endpoints left reachable
- SSRF via API- an endpoint that fetches a user-supplied URL server-side without an allowlist
- Improper inventory management- old/beta API versions still deployed and unmonitored alongside current ones
Test for Broken Object Level Authorization (BOLA) explicitly — it's OWASP API Security's #1 risk year after year, and schema validation or a valid JWT will never catch it because the request is syntactically and even authentically valid, just for the wrong object.