OWASP API Security Top 10
OWASP (the Open Worldwide Application Security Project) is a non-profit community that publishes free security guidance. Besides the well-known Top 10 for web applications, it keeps a separate list for APIs: the OWASP API Security Top 10. The current edition is from 2023. Security reviewers, penetration testers and interviewers use its codes (API1, API2...) as a shared language, so a back-end developer should know each item and what it looks like in Node.js code.
Security Best Practices covered general habits such as helmet, rate limiting and injection. This lesson goes through the API list item by item, links to lessons where a fix already appears, and adds code for the items not covered yet: resource limits, business-flow abuse and server-side request forgery.
The list at a glance
| Code | Risk | One-line Node.js defence |
|---|---|---|
| API1 | Broken Object Level Authorization | Check the owner of every record ID on every request |
| API2 | Broken Authentication | Hashed passwords, rate-limited login, short-lived verified tokens |
| API3 | Broken Object Property Level Authorization | Allow-list fields on writes, strip private fields on reads |
| API4 | Unrestricted Resource Consumption | Cap page sizes, body sizes, uploads, timeouts and paid calls |
| API5 | Broken Function Level Authorization | Permission checks on every admin or staff route |
| API6 | Unrestricted Access to Sensitive Business Flows | Limits per account, phone or card, not only per IP |
| API7 | Server Side Request Forgery | Validate URLs your server fetches; block internal addresses |
| API8 | Security Misconfiguration | Safe errors, strict CORS, security headers, no defaults |
| API9 | Improper Inventory Management | Know and document every endpoint and version; retire old ones |
| API10 | Unsafe Consumption of APIs | Treat third-party responses as untrusted input |
API1, API3 and API5: authorisation at three levels
These three are all about who may do what, and together they cause a large share of real API breaches:
- API1, object level: Rohan reads
/api/orders/105, which belongs to Priya. Fix: check ownership in every handler, ideally inside the query (WHERE id = ? AND customer_id = ?). - API3, property level: a user reads fields they should not see (password hash, internal notes) or writes fields they should not change (
role,walletBalance). Fix: strict input schemas and output filtering. - API5, function level: a normal user calls an admin route such as
DELETE /api/users/12simply by guessing the URL. Fix: a permission middleware on every privileged route, and deny by default.
All three are built step by step in Authorisation: Roles & Ownership.
API2: broken authentication
Attackers guess passwords, reuse leaked ones or abuse weak tokens. The defences are in earlier lessons: Argon2id or bcrypt hashing (Password Hashing & Sessions), strict rate limits on login, OTP and reset routes (Security Best Practices), and short-lived, properly verified JWTs (JWT Authentication). Password-reset links must be random, single-use and expire quickly.
API4: unrestricted resource consumption
Every request uses CPU, memory, bandwidth and sometimes money (SMS, email, payment or AI API calls). An API with no limits can be knocked over, or can produce a huge bill, without any clever hacking. A common example is the page size: GET /api/products?limit=1000000 asks your database for every row at once. Cap it in the query schema:
import { z } from "zod";
const ListQuery = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
search: z.string().trim().max(50).optional(),
});
console.log(ListQuery.parse({}));
console.log(ListQuery.parse({ page: "3", limit: "50" }));
const bad = ListQuery.safeParse({ limit: "1000000" });
console.log(bad.success, bad.error.issues[0].message);{ page: 1, limit: 20 }
{ page: 3, limit: 50 }
false Too big: expected number to be <=100Limit everything that can grow:
- Request bodies:
express.json({ limit: "100kb" }). - Uploads: file size and file count limits in multer (File Uploads).
- Text fields: a maximum length on every string, including search boxes.
- Time: Node.js's
httpserver already stops requests that take longer thanrequestTimeout(300,000 ms by default on Node.js 22) and waits at mostheadersTimeout(60,000 ms) for headers; set shorter values for a simple JSON API. Give outgoing calls timeouts too (Calling Other APIs with fetch). - Expensive work: move it to a queue (Background Jobs & Queues) instead of running it inside the request.
API6: sensitive business flows
Some attacks need no bug at all. A bot buys all the limited sale tickets in seconds, creates 10,000 fake accounts to claim a "₹100 off first order" coupon, or presses "send OTP" thousands of times, costing you money for every SMS. The code works as written; the flow is abused at machine speed.
An IP-based rate limit is not enough, because attackers rotate through many IP addresses. Add limits on the business object itself: per phone number, per account, per card, per coupon:
// A per-phone-number limit for "send OTP": max 3 per hour
const WINDOW_MS = 60 * 60 * 1000;
const MAX_PER_WINDOW = 3;
const sent = new Map(); // phone -> array of timestamps
export function allowOtp(phone, now = Date.now()) {
const recent = (sent.get(phone) ?? []).filter((t) => now - t < WINDOW_MS);
if (recent.length >= MAX_PER_WINDOW) return false;
recent.push(now);
sent.set(phone, recent);
return true;
}
const start = Date.now();
for (let i = 1; i <= 5; i++) {
console.log(`request ${i} for 9812345678:`, allowOtp("9812345678", start + i * 1000));
}
console.log("other number:", allowOtp("9898989898", start));
console.log("after an hour:", allowOtp("9812345678", start + WINDOW_MS + 2000));request 1 for 9812345678: true request 2 for 9812345678: true request 3 for 9812345678: true request 4 for 9812345678: false request 5 for 9812345678: false other number: true after an hour: true
A Map in memory works for one process only; with several processes, keep the counters in Redis (Caching with Redis). CAPTCHA on sign-up and alerts when a flow suddenly gets busier also help.
API7: server-side request forgery (SSRF)
Some features make your server fetch a URL that the user supplies: "import product photo from URL", "add a webhook", "preview this link". SSRF happens when an attacker gives a URL that points inside your network, such as http://localhost:6379 (your Redis) or http://169.254.169.254/ (the cloud metadata service, which can reveal server credentials). Your server makes the request with its own network access, which the attacker does not have.
Defend by allowing only https and refusing addresses that belong to private or local networks. Node.js has a built-in BlockList class for exactly this check:
// ssrf-guard.js: allow only public https URLs
import { lookup } from "node:dns/promises";
import { BlockList, isIP } from "node:net";
const blocked = new BlockList();
blocked.addSubnet("127.0.0.0", 8); // this machine
blocked.addSubnet("10.0.0.0", 8); // private networks
blocked.addSubnet("172.16.0.0", 12);
blocked.addSubnet("192.168.0.0", 16);
blocked.addSubnet("169.254.0.0", 16); // link-local, includes cloud metadata 169.254.169.254
blocked.addSubnet("0.0.0.0", 8);
blocked.addAddress("::1", "ipv6");
blocked.addSubnet("fc00::", 7, "ipv6"); // private IPv6
blocked.addSubnet("fe80::", 10, "ipv6");
export async function assertPublicUrl(text) {
const url = new URL(text); // throws on garbage
if (url.protocol !== "https:") throw new Error("Only https URLs are allowed");
const host = url.hostname.replace(/^\[|\]$/g, "");
const addresses = isIP(host)
? [{ address: host, family: isIP(host) }]
: await lookup(host, { all: true });
for (const { address, family } of addresses) {
if (blocked.check(address, family === 6 ? "ipv6" : "ipv4")) {
throw new Error(`Address ${address} is not allowed`);
}
}
return url;
}import { assertPublicUrl } from "./ssrf-guard.js";
const tries = [
"http://shop.example.in/logo.png",
"https://169.254.169.254/latest/meta-data/",
"https://localhost:3000/admin",
"https://[::1]/admin",
"https://192.168.1.10/router",
"https://203.0.113.50/photo.jpg",
];
for (const t of tries) {
try {
await assertPublicUrl(t);
console.log("OK ", t);
} catch (err) {
console.log("BLOCKED", t, "-", err.message);
}
}BLOCKED http://shop.example.in/logo.png - Only https URLs are allowed BLOCKED https://169.254.169.254/latest/meta-data/ - Address 169.254.169.254 is not allowed BLOCKED https://localhost:3000/admin - Address ::1 is not allowed BLOCKED https://[::1]/admin - Address ::1 is not allowed BLOCKED https://192.168.1.10/router - Address 192.168.1.10 is not allowed OK https://203.0.113.50/photo.jpg
The host name is looked up first because localhost, or any domain an attacker controls, can point to an internal address. This check is a strong first layer but not complete: fetch() looks the name up again and could get a different answer, and a redirect could lead somewhere internal. So also pass redirect: "error" to fetch(), keep a timeout and a size limit, and where possible allow only a fixed list of trusted domains.
API8, API9 and API10
API8, security misconfiguration: stack traces in error responses, cors() open to every site on an API that uses cookies, missing security headers, debug mode in production, default passwords on databases. Fixes: the central error handler from Error Handling, helmet, an allow-list for CORS, and settings checked at startup.
API9, improper inventory management: the team builds /api/v2 but /api/v1 keeps running with old, weaker checks, or a forgotten /api/debug/users route stays online. Keep an up-to-date OpenAPI document of every endpoint and version, remove old versions on a published date, and never deploy test or debug routes.
API10, unsafe consumption of APIs: developers check user input but trust whatever a payment gateway or courier API sends back. If that service is hacked or buggy, its data flows straight into your database. Validate third-party responses with a schema, use HTTPS and set timeouts.
Common mistakes
- Relying only on IP rate limits. Business flows need limits per account, phone number or coupon.
- No maximum on
limit, string lengths or uploads. One request can exhaust memory or the database. - Fetching user-supplied URLs without checks. Block private addresses and refuse redirects.
- Leaving old API versions and debug routes running. Keep an inventory and switch them off.
- Trusting partner APIs completely. Their responses are input; validate them.
Key points
- The OWASP API Security Top 10 (2023) is the shared checklist for API risks; know each code.
- API1, API3 and API5 are authorisation at object, field and function level; check all three on the server.
- API4 and API6 are about limits: cap sizes, time and cost, and limit business flows per account or phone.
- API7 SSRF: allow only https, block private and local addresses, refuse redirects, set timeouts.
- API8 to API10: safe configuration, a full inventory of endpoints, and validation of third-party data.
Exercise
A "set my profile photo from a URL" route receives { "photoUrl": "..." }. Write the route handler that: validates the body with Zod (a URL string of at most 300 characters), calls assertPublicUrl(), then fetches the image with a 5-second timeout and redirect: "error", rejects any response whose content-type does not start with image/, and rejects images larger than 2 MB using the content-length header. Answer 400 with a clear message for each problem.
Show solution
import { z } from "zod";
import { assertPublicUrl } from "./ssrf-guard.js";
const Body = z.object({ photoUrl: z.string().url().max(300) });
const MAX_BYTES = 2 * 1024 * 1024;
app.post("/api/me/photo-from-url", async (req, res) => {
const { photoUrl } = Body.parse(req.body);
try {
const url = await assertPublicUrl(photoUrl);
const img = await fetch(url, { redirect: "error", signal: AbortSignal.timeout(5000) });
if (!img.ok) return res.status(400).json({ error: `Image server answered ${img.status}` });
if (!img.headers.get("content-type")?.startsWith("image/")) {
return res.status(400).json({ error: "That URL is not an image" });
}
if (Number(img.headers.get("content-length") ?? Infinity) > MAX_BYTES) {
return res.status(400).json({ error: "Image must be 2 MB or smaller" });
}
const bytes = Buffer.from(await img.arrayBuffer());
// save bytes with a random file name, as in the File Uploads lesson
res.status(201).json({ saved: true, size: bytes.length });
} catch (err) {
res.status(400).json({ error: err.message });
}
});A missing content-length counts as too big, so the server never downloads an image of unknown size. For full safety you would also count bytes while streaming, because a server can send more than its header says.
Quick Quiz
1. Rohan changes the order ID in the URL and sees Priya's order. Which OWASP API risk is this?
The API did not check that the object belongs to the caller.
2. A bot sends thousands of "send OTP" requests for one phone number from many IP addresses. What is the best extra defence?
Business-flow limits follow the object being abused.
3. Why is http://169.254.169.254/ a dangerous URL for your server to fetch?
SSRF lets attackers use your server's network position.
4. Which item covers a forgotten /api/v1 that still runs with old, weaker checks?
Keep an inventory of every endpoint and version, and retire old ones.
Learn Node.js with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
