EthicsComputerCourses
Join training +91 90532 10052

OWASP API Security Top 10

Node.js · Lesson 35 of 43 Advanced 9 min read Updated

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

CodeRiskOne-line Node.js defence
API1Broken Object Level AuthorizationCheck the owner of every record ID on every request
API2Broken AuthenticationHashed passwords, rate-limited login, short-lived verified tokens
API3Broken Object Property Level AuthorizationAllow-list fields on writes, strip private fields on reads
API4Unrestricted Resource ConsumptionCap page sizes, body sizes, uploads, timeouts and paid calls
API5Broken Function Level AuthorizationPermission checks on every admin or staff route
API6Unrestricted Access to Sensitive Business FlowsLimits per account, phone or card, not only per IP
API7Server Side Request ForgeryValidate URLs your server fetches; block internal addresses
API8Security MisconfigurationSafe errors, strict CORS, security headers, no defaults
API9Improper Inventory ManagementKnow and document every endpoint and version; retire old ones
API10Unsafe Consumption of APIsTreat 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/12 simply 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:

JavaScript
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);
Output
{ page: 1, limit: 20 }
{ page: 3, limit: 50 }
false Too big: expected number to be <=100

Limit 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 http server already stops requests that take longer than requestTimeout (300,000 ms by default on Node.js 22) and waits at most headersTimeout (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:

JavaScript
// 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));
Output
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:

JavaScript
// 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;
}
JavaScript
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);
  }
}
Output
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
JavaScript
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?

2. A bot sends thousands of "send OTP" requests for one phone number from many IP addresses. What is the best extra defence?

3. Why is http://169.254.169.254/ a dangerous URL for your server to fetch?

4. Which item covers a forgotten /api/v1 that still runs with old, weaker checks?

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