“Oops, we got scraped” is rarely a single bug. It’s usually a chain: an endpoint that leaks too much data, a token that lasts too long, a missing authorization check (hello, IDOR), and no rate limits to slow an attacker down. This post is a practical API security checklist for the things that actually bite teams: auth, rate limiting, and common abuse cases like enumeration, credential stuffing, and automated scraping.
Quickstart
If you only have 30–60 minutes, do these steps in order. They’re the highest ROI moves to stop the most common API failures: broken authorization, unlimited data extraction, and “silent” abuse.
1) Lock down identity + tokens
- Require TLS everywhere; reject plain HTTP at the edge
- Use short-lived access tokens (minutes), and rotate refresh tokens
- Validate issuer/audience/expiry; don’t accept “none” algorithms
- Store secrets in a secret manager (not in code or logs)
2) Add authorization checks per resource
- Never trust client-provided
user_id,tenant_id, orrole - Enforce “subject can access object” on every read/write
- Default-deny; allow by explicit policy
- Test for IDOR: “can I fetch someone else’s object by ID?”
3) Put rate limits where it matters
- Apply global + per-endpoint limits (login, search, exports)
- Limit by multiple keys: IP + account + API key + tenant
- Return
429with a predictable body; addRetry-Afterif you can - Cap pagination and result size (scraping loves “unbounded”)
4) Plan for abuse, not just bugs
- Define top abuse cases: scraping, enumeration, brute-force, bot signups
- Log the right signals: actor, resource, result, latency (not secrets)
- Alert on spikes and “impossible travel” patterns
- Add a kill switch: revoke keys / block actors quickly
If your API is public or partner-facing, prioritize in this order: authorization correctness → rate limits + caps → token hygiene → abuse monitoring. A perfect OAuth setup won’t save you from a missing object-level permission check.
Overview
This is a checklist-style guide to shipping an API that’s harder to exploit and cheaper to operate. It focuses on three areas that cause most real incidents: authentication (who are you?), authorization (are you allowed?), and abuse controls (what happens when a bot tries 10M requests?).
What this post covers
| Area | What can go wrong | What you’ll implement |
|---|---|---|
| Auth (identity) | Stolen tokens, weak keys, long-lived credentials | Token validation, rotation, scoped access, secure storage |
| Authorization (permissions) | IDOR, role confusion, tenant breakout | Resource-level checks, default-deny policies, safe patterns |
| Rate limits + quotas | Scraping, brute force, expensive queries, outages | Global + per-route limits, caps, backoff, predictable 429s |
| Abuse cases (real attackers) | Enumeration, credential stuffing, bots, multi-accounting | Signals + alerts, throttles, anomaly rules, revocation |
The goal isn’t “perfect security” (doesn’t exist). The goal is a set of guardrails that make abuse expensive, mistakes contained, and incidents recoverable.
Assume the client is hostile, the network is observable, tokens will leak eventually, and bots will try your most valuable endpoints first (login, search, export, password reset).
Core concepts
A good API security checklist is mostly about correctness under pressure: when you add new endpoints fast, when traffic spikes, and when someone intentionally tries to break your assumptions. These concepts help you build guardrails that keep working at scale.
Authentication vs authorization (and why people mix them up)
Authentication (AuthN)
Proves identity: “Who is this request acting as?”
- Sessions, access tokens, API keys, mTLS client certs
- Token verification (issuer, audience, expiry, signature)
- Key rotation and revocation
Authorization (AuthZ)
Grants permission: “Is this identity allowed to do this to this resource?”
- Scopes/roles/claims are inputs, not the decision itself
- Object-level permission checks (resource ownership / tenant)
- Default-deny policies and explicit allow rules
“We have JWTs, so we’re secure” is a trap. JWTs solve identity. They don’t automatically solve authorization. Most data leaks come from missing object-level checks, not from missing login.
Threat-driven design: protect what’s valuable and abusable
Attackers don’t scan every endpoint equally. They focus on endpoints that: (1) return lots of data, (2) change account state, or (3) cost you money. A practical way to prioritize is to label each route with data sensitivity and abuse risk.
| Endpoint type | Common abuse pattern | Minimum controls |
|---|---|---|
| Login / OTP / password reset | Credential stuffing, brute force, SMS pumping | Strict rate limits, bot checks, lockouts, anomaly alerts |
| Search / list / analytics | Scraping, enumeration, expensive queries | Pagination caps, query constraints, per-tenant quotas |
| Exports / bulk downloads | Data exfiltration, “one-click” scraping | Explicit permissions, slow-path review, logging + alerts |
| Write actions (billing, role changes) | Fraud, privilege escalation | Strong auth, step-up checks, idempotency, audit trails |
Rate limiting vs quotas (they solve different problems)
Rate limiting
Controls burst: requests per second/minute.
- Stops brute force and request floods
- Protects your infrastructure during spikes
- Pairs well with “retry with backoff” clients
Quotas / budgets
Controls total usage: requests per day/month or cost units.
- Stops slow scraping over weeks
- Aligns with billing/tiers
- Prevents “accidental” runaway clients
Abuse cases: think like a bot
Abuse is different from hacking: the attacker may use perfectly valid requests. Your job is to make misuse detectable and expensive without punishing legitimate users.
Common API abuse patterns
- Scraping: walking IDs, harvesting search results, bulk export abuse
- Enumeration: probing for existing users/objects via error messages
- Credential stuffing: leaked password lists + automated login
- Replay: resending captured requests (webhooks, signed requests)
- Resource exhaustion: expensive filters, wide date ranges, huge payloads
Controls that work in practice
- Uniform errors for “not found” vs “not allowed” where appropriate
- Pagination + field selection + max limits (caps everywhere)
- Per-route limits for risky endpoints + progressive throttling
- Audit logs + anomaly alerts + fast revocation paths
- Idempotency keys for writes; signature/timestamp for webhooks
Most scraping incidents happen because APIs are too generous by default: unlimited page sizes, unlimited filters, unlimited search results, unlimited retries. Add caps early; relaxing caps later is easier than tightening them after an incident.
Step-by-step
This is a practical implementation path you can apply to an existing API or bake into a new one. The key is to avoid “security as a feature” and instead build default-safe patterns that every endpoint inherits.
Step 1 — Inventory endpoints and classify risk
Start by listing every route (including internal/admin) and adding two labels: data sensitivity (public, internal, PII, financial) and abuse risk (low, medium, high). This immediately tells you where to focus rate limits, scopes, and audit logging.
Mini checklist
- Mark endpoints that return lists/search/export (scraping magnets)
- Mark endpoints that change permissions/billing (high impact)
- Mark “expensive” endpoints (complex joins, external calls, ML inference)
- Decide: what’s the maximum data any single request can return?
Step 2 — Choose an auth strategy (and document it)
Your API security checklist should start with one question: Who calls this API? A browser app, a mobile app, server-to-server integrations, or partners? Different callers imply different credentials. Whatever you choose, document it in OpenAPI so it’s visible and reviewable.
Use OAuth/OIDC for user-based access and delegated permissions. Use API keys (or mTLS) for server-to-server integrations, but treat keys like passwords: rotate, scope, and revoke.
Here’s a compact OpenAPI example showing bearer tokens and per-endpoint security requirements. This doesn’t implement security by itself, but it forces clarity and makes reviews much easier.
openapi: 3.0.3
info:
title: Example API
version: 1.0.0
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
apiKeyAuth:
type: apiKey
in: header
name: X-API-Key
paths:
/v1/users/{id}:
get:
summary: Get a user (self or admin)
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
"200":
description: OK
"401":
description: Unauthorized
"403":
description: Forbidden
Step 3 — Implement authorization as a policy, not scattered if-statements
Authentication is a gate. Authorization is the map of what’s allowed. The safest pattern is: (1) resolve the actor, (2) resolve the resource, (3) ask a policy function if the action is allowed. That gives you a single place to test, audit, and change behavior.
What “good” looks like
- One policy function per resource type (user, org, project)
- Resource ownership checks (including tenant boundaries)
- Explicit deny for unknown roles/scopes
- Auditable decisions (why was access granted?)
What to avoid
- “If admin then allow everything” without scoping
- Client-supplied IDs used to fetch other users’ data
- Relying on UI to hide buttons instead of API enforcement
- Different authorization logic across endpoints for the same resource
Step 4 — Add request guards: validation, caps, and safe defaults
Most abuse relies on endpoints being too flexible. Add guardrails so requests stay inside safe bounds: validate input, cap pagination, and reject payloads that exceed your limits. This improves both security and reliability.
| Guard | Why it matters | Default suggestion |
|---|---|---|
| Max page size | Prevents bulk extraction per request | 25–100 depending on object size |
| Max filter width | Stops expensive “query everything” requests | Whitelist fields; reject unknown filters |
| Max payload size | Prevents memory pressure and parsing abuse | Limit at edge + app layer |
| Uniform error shapes | Reduces enumeration signal | Consistent codes; avoid verbose hints |
Step 5 — Implement rate limits that match abuse patterns
Rate limiting should be multi-dimensional (IP, account, tenant, API key) and route-aware. A single global limit is better than nothing, but it won’t stop targeted abuse (e.g., login or export endpoints).
Where to apply limits
- Edge gateway/WAF (cheap, early)
- App middleware (context-aware)
- Data layer protections (query timeouts, statement limits)
Good limiter keys
- IP: good for anonymous traffic (but NAT/shared IPs exist)
- API key: best for integrations
- User/account: best for logged-in actions
- Tenant/org: prevents one customer taking you down
Below is a practical Node/Express example that: (1) validates a bearer token, (2) enforces a per-user rate limit, and (3) applies a stricter limit for a high-risk route (login).
import express from "express";
import rateLimit from "express-rate-limit";
import jwt from "jsonwebtoken";
const app = express();
app.use(express.json());
// 1) Global baseline: protects your infra from obvious floods
app.use(rateLimit({
windowMs: 60_000,
limit: 300,
standardHeaders: true,
legacyHeaders: false
}));
function requireAuth(req, res, next) {
const header = req.headers.authorization || "";
const token = header.startsWith("Bearer ") ? header.slice(7) : null;
if (!token) return res.status(401).json({ error: "unauthorized" });
try {
// In production: validate issuer/audience and use a JWKS-based verifier for OIDC providers.
const payload = jwt.verify(token, process.env.JWT_PUBLIC_KEY, { algorithms: ["RS256"] });
req.user = { id: payload.sub, role: payload.role, scope: payload.scope || "" };
return next();
} catch {
return res.status(401).json({ error: "unauthorized" });
}
}
// 2) Per-user limiter (fallback to IP if unauthenticated)
const userLimiter = rateLimit({
windowMs: 60_000,
limit: 120,
keyGenerator: (req) => (req.user?.id ? `u:${req.user.id}` : `ip:${req.ip}`),
standardHeaders: true,
legacyHeaders: false
});
app.get("/v1/me", requireAuth, userLimiter, (req, res) => {
res.json({ id: req.user.id });
});
// 3) High-risk endpoint: stricter limiter
app.post("/v1/login", rateLimit({ windowMs: 60_000, limit: 10 }), (req, res) => {
res.status(501).json({ error: "not_implemented" });
});
app.listen(3000);
If you only rate-limit “valid users,” you can accidentally leak whether an account exists. Prefer limits that apply before revealing existence (especially for login/reset flows), and keep errors consistent.
Step 6 — Harden against replay and webhook abuse
Webhooks and signed requests are common “forgotten” surfaces. The core idea is simple: include a timestamp and signature, verify both, and reject old requests. This prevents replay (an attacker re-sends a captured request later).
Example: verify a webhook signature using HMAC, enforce a maximum clock skew, and compare signatures safely. This belongs in your API security checklist if you accept inbound events from third parties.
import hmac
import hashlib
import time
MAX_SKEW_SECONDS = 300 # 5 minutes
def verify_webhook(secret: str, body: bytes, header_sig: str, header_ts: str) -> bool:
"""
header_sig example: "v1=hex_digest"
header_ts: unix timestamp as string
"""
try:
ts = int(header_ts)
except (TypeError, ValueError):
return False
# Reject replays / very old requests
if (time.time() - ts) > MAX_SKEW_SECONDS:
return False
# Sign (timestamp + "." + body) to bind time + payload
msg = str(ts).encode("utf-8") + b"." + body
digest = hmac.new(secret.encode("utf-8"), msg, hashlib.sha256).hexdigest()
expected = f"v1={digest}"
# Constant-time comparison to avoid timing attacks
return hmac.compare_digest(expected, header_sig)
Step 7 — Add observability: logs you can use during an incident
Logging isn’t “nice to have” for security. It’s your reconstruction tool when something goes wrong. Log enough to answer: who did what to which resource, from where, and what happened.
Log these fields
- request_id / trace_id
- actor: user_id, api_key_id, tenant_id
- route + method + status code + latency
- resource id(s) accessed (when applicable)
- abuse signals: rate-limit hit, auth failure reason (coarse)
Never log these
- Passwords, one-time codes, raw tokens
- Authorization headers or full cookies
- Secret keys (webhook secrets, API private keys)
- Full PII payloads (log minimal references instead)
Step 8 — Test the checklist (don’t trust “it seems fine”)
A checklist is only real when it’s tested. Add automated tests for the scariest failures: missing authorization, privilege escalation, and “infinite” list endpoints.
High-value tests
- IDOR tests: user A cannot read/write user B’s resources
- Scope tests: token without scope cannot call protected route
- Rate limit tests: repeated requests eventually return 429
- Pagination cap tests: page_size above max is rejected or clamped
- Abuse flow tests: login/reset endpoints behave consistently
Common mistakes
These are patterns behind “we had auth, why did we still leak data?” The fixes are usually straightforward, but you have to be intentional because frameworks don’t automatically enforce them.
Mistake 1 — Confusing AuthN with AuthZ
Valid token ≠ permission. A token can be real and still not allowed to access a specific object.
- Fix: enforce resource-level checks on every read/write.
- Fix: test IDOR explicitly (the #1 API data leak pattern).
Mistake 2 — Trusting client-provided identifiers
If the client can pick user_id or tenant_id, an attacker will pick someone else’s.
- Fix: derive identity from verified tokens (server-side), not request JSON.
- Fix: ignore/override sensitive fields even if the UI hides them.
Mistake 3 — Unbounded lists and “free” exports
Scraping loves endpoints with large pages, rich fields, and unlimited filters.
- Fix: cap pagination and limit fields; require explicit permissions for exports.
- Fix: add quotas and route-specific throttles for list/search endpoints.
Mistake 4 — Rate limiting only by IP
Bots rotate IPs; legitimate users share IPs (NAT, mobile networks). IP-only is noisy and bypassable.
- Fix: limit by API key / account / tenant, with IP as a fallback.
- Fix: use stricter limits on login/reset and other high-risk routes.
Mistake 5 — Leaking signals through errors
“User not found” and “wrong password” are useful to attackers. Same for “object exists but forbidden”.
- Fix: keep external errors consistent; log detailed reasons internally.
- Fix: consider returning 404 for unauthorized access to private objects (context-dependent).
Mistake 6 — Logging secrets and tokens
This turns your logs into a credential vault. Leaked logs become leaked access.
- Fix: redact Authorization headers, cookies, and sensitive fields.
- Fix: add log-scanning rules to CI and runtime observability.
Pick your most sensitive list endpoint and ask: “How many requests would it take to export the entire dataset?” If the answer is “not many,” tighten caps, add quotas, and require explicit permission for bulk access.
FAQ
Do I need OAuth if I already use API keys?
Not always. API keys work well for server-to-server integrations, but they’re coarse-grained unless you add scopes/permissions on top. OAuth/OIDC shines when access is tied to a user identity, delegated access (third-party apps), or you need standard flows like authorization code. Many teams use both: OAuth for user access and API keys for partner integrations.
What’s the difference between a scope and authorization?
A scope is a hint; authorization is the decision. Scopes (or roles/claims) describe what a token might be allowed to do, but your API still needs to check the specific resource: user A with “read” scope should not automatically read user B’s private object.
How do I choose rate limit numbers?
Start with intent and risk. Set looser limits for low-risk reads and stricter limits for high-risk routes (login/reset, exports, search). Use observed production traffic to tune. The important part is having some limit, returning predictable 429s, and limiting by the right keys (account/tenant/API key), not only IP.
How do I prevent scraping if the API is public?
You can’t “ban scraping” with one trick. You reduce it by adding caps (page size, fields), introducing quotas, requiring authentication for higher-value data, using route-specific throttles, and watching for automated behavior (high fan-out, sequential ID access, abnormal user agents). Also consider “bulk access” as a separate product feature with explicit permissions and audit logs.
Should I return 404 or 403 for unauthorized resources?
It depends on your enumeration risk. Returning 404 for unauthorized access can hide whether a resource exists, which reduces enumeration. Returning 403 is clearer for legitimate users. A common compromise: use 404 for sensitive resources and keep detailed reasons in internal logs.
How do I secure webhooks and signed requests?
Verify signatures and prevent replay. Use an HMAC (or asymmetric signature), include a timestamp, reject old requests, and use constant-time signature comparison. Also log verification failures and rate-limit webhook endpoints to prevent floods.
Cheatsheet
A scan-fast API security checklist you can paste into a PR description, run during a launch review, or keep as your “definition of done”.
Auth (identity)
- TLS everywhere; HSTS where applicable
- Tokens validated (signature, issuer, audience, expiry)
- Short-lived access tokens; refresh tokens rotated
- Keys/tokens stored securely; no secrets in logs
- Revocation path exists (disable user / revoke key)
Authorization (permissions)
- Object-level checks on every read/write (IDOR-proof)
- Tenant boundaries enforced (org_id is server-derived)
- Default-deny policies; explicit allow rules
- “Admin” behavior scoped and audited
- Tests cover: self vs other, role boundaries, scope boundaries
Rate limits + caps
- Global limit at edge + route-specific limits for risky endpoints
- Limits keyed by: API key / user / tenant (IP as fallback)
- Pagination caps + field limits + max payload size
- Predictable 429 responses; clients can back off
- Quotas/budgets for slow scraping and cost control
Abuse cases + monitoring
- Top abuse cases documented (scraping, enumeration, stuffing)
- Audit logs: actor + resource + result + request_id
- Alerts on spikes, repeated auth failures, export anomalies
- Replay protection for webhooks (timestamp + signature)
- Kill switch: blocklist/revoke quickly without deploy
PR review checklist (copy/paste)
- Does this endpoint expose new data or increase access? (fields, joins, “include=all”)
- Is authorization checked for the specific resource ID?
- Is there a cap on pagination, filters, and payload size?
- What’s the abuse path (scrape/brute force)? What limits stop it?
- Do logs contain enough signal to investigate without leaking secrets?
Wrap-up
The best API security isn’t a single library or vendor. It’s a set of defaults you apply consistently: verify identity, enforce object-level authorization, cap and throttle what can be abused, and log the signals you’ll need when something goes wrong.
What to do next (15 minutes)
- Pick your top 3 risky endpoints (login, search, export)
- Add or tighten route-specific rate limits
- Confirm object-level authorization exists (and add an IDOR test)
- Cap pagination and remove “too generous” defaults
What to do next (this week)
- Document auth + scopes in OpenAPI
- Centralize authorization policies per resource
- Set up alerts for abuse signals and export anomalies
- Add a fast revocation path for tokens/keys
If a bot can turn your API into a data pump with valid requests, you don’t have a “hacking problem” — you have an abuse controls problem. Solve it with caps, limits, and visibility.
If you want to go deeper, the related posts below pair well with this checklist: threat modeling, modern auth, DevSecOps, and common real-world attack patterns.
Quiz
Quick self-check (demo). This quiz is auto-generated for cyber / security / api.