Backend Security & Quality

Lesson 1 of 5

API Security

"There are two types of companies: those that have been hacked, and those that don't yet know they have been hacked." — John Chambers, former CEO of Cisco

Every endpoint you ship is a door, and the moment it's public, someone you've never met is trying the handle. Not metaphorically — literally, within minutes. Automated scanners crawl the internet cataloguing APIs, fuzzing parameters, and swapping IDs in URLs to see what falls out. They are patient, tireless, and they only need you to be careless once. The frontend an honest user sees is irrelevant to them; they speak directly to your API with curl and a script, sending exactly the bytes you hoped no one would send.

This chapter is the security-minded tour of that reality — the specific attacks a backend engineer must defend against, and the defense for each. It is organized around the industry's shared map of the terrain, the OWASP API Security Top 10, but you don't need to memorize a list. You need a reflex: at every boundary, ask "what if this input is a lie, and this caller is an attacker?" You've met that reflex before — as never trust the client (Chapter 1) and as validation's all input is evil (Part II). Here we generalize it into a defensive posture for the whole API.

The big question

An attacker can send your API any bytes they like, from any tool, in any order, as often as they want — and will. Given that, what are the handful of mistakes that actually get systems breached, and what is the concrete defense for each?

Your API is the attack surface

For a classic website, "the attack surface" was fuzzy. For an API, it is precise and honest: it is every endpoint, every parameter, every field you accept or return. There is no hiding logic "in the app" — the app is untrusted client code, and everything of value flows through the API, in the open, described (helpfully for the attacker) by your own documentation.

This clarity is a gift, because it means security is not a vague aura you sprinkle on at the end. It is a finite set of boundaries you can enumerate and defend. The dangerous move is treating security as a feature — a task on the backlog — rather than a property of every handler you write. A single endpoint that forgot one check is the whole breach; attackers find the weakest door, not the average one.

Analogy Think of your API as a building with a hundred doors and no lobby. There's no single guarded entrance where you check everyone once; each door opens directly onto something valuable. Security isn't a guard at "the" entrance — it's a lock on every door, and the intruder walks the whole perimeter rattling each one. Ninety-nine perfect locks and one door left ajar is not "99% secure." It's breached.

The map: OWASP API Security Top 10

OWASP (the Open Worldwide Application Security Project) publishes a periodically updated ranking of the risks that actually cause API breaches, drawn from real incidents. You should recognize every item and, more importantly, name its defense. Here are the ones that matter most, grouped by the reflex that defeats them:

Risk What it is The defense
BOLA / IDOR (#1) Acting on a resource by ID without checking the caller owns it Object-level authz on every access (Part IV)
Broken authentication Weak/guessable login, tokens that never expire, bad password storage Proper authN: hashing, expiry, MFA (Part IV)
Broken object-property authz Reading/writing fields the caller shouldn't (mass assignment + excessive exposure) Allowlist fields in and out
Injection User input parsed as SQL / NoSQL / a shell command Parameterize; never concatenate (Vol 7, Part II)
Security misconfiguration Debug mode on, defaults unchanged, verbose errors, permissive CORS Harden config; fail securely
SSRF Server fetches an attacker-supplied URL into your network Allowlist outbound destinations
Unrestricted resource consumption No rate limits → brute-force, scraping, cost/DoS Rate limit and quota (Part VI)
Improper inventory management Forgotten old/undocumented endpoints still live Know and retire every endpoint

Notice the pattern: almost every one is a check that should have happened but didn't, or input that was trusted when it should have been treated as hostile. Let's walk the big ones with their defenses.

BOLA / IDOR — the number-one API risk

At the top of the list, year after year, sits Broken Object-Level Authorization (BOLA), better known by its older name IDOR (Insecure Direct Object Reference). We met it in the authorization chapter (Part IV); it is important enough to state again here, because it is the single most common serious API bug on the internet.

The shape is always the same. A handler receives an ID from the client — GET /invoices/8842, DELETE /posts/43 — loads that object, and acts on it, having checked only that the caller is logged in, never that the caller is allowed to touch this specific object. The ID in the URL is user-supplied input: an attacker simply increments it, or guesses it, and reads or destroys other people's data. The fix is boring and endlessly forgotten: after you load the resource, verify this caller owns (or is otherwise authorized for) this exact object — every time.

Run the two versions side by side. The insecure one leaks; the secure one blocks the attacker and allows the legitimate owner:

TypeScript — Try it Yourself
Loading editor…
Type check + Console

The insecure function happily hands Ada someone else's $90 invoice; the secure one refuses, and refuses in a way that doesn't even confirm inv_2 exists. Note the two defensive details: the ownership comparison happens after the load (you can't check ownership on a resource you haven't fetched), and the denial returns a 404 rather than a 403 so an attacker can't map out which IDs are real. Route every object access through a check shaped like this — never a bare db.get(id).

In production Predictable, sequential IDs (/users/1, /users/2) make IDOR trivial to exploit at scale — an attacker just counts upward. Using non-guessable identifiers (UUIDs) raises the bar, but it is defense in depth, not the fix: a leaked or shared UUID is still accessible without an ownership check. The real fix is always the authorization check. Opaque IDs make enumeration harder; only object-level authz makes it safe.

Broken authentication

If authorization (BOLA) is about what you may touch, authentication is the gate before it: proving who you are. Broken authentication is its own top-ten category, and it's a grab-bag of failures we covered in depth in Part IV: passwords stored in plaintext or with fast hashes instead of bcrypt/argon2; login endpoints with no rate limiting, inviting credential-stuffing and brute force; tokens that never expire, can't be revoked, or are signed with alg: none; session IDs that don't rotate after login. The defenses all live in Part IV — hash slowly and with salt, expire and scope tokens, rate-limit auth endpoints (Part VI), and prefer battle-tested libraries over hand-rolled crypto. The one-line reminder for this chapter: authentication is the front door of the whole API, so it deserves the strongest lock and the loudest alarm.

Injection — never build a command from strings

Injection is the oldest trick in the book and still on every top-ten list: it happens whenever user input is concatenated into a string that another system interprets — SQL, a NoSQL query object, a shell command, an LDAP filter. The interpreter can't tell your intent from the attacker's payload; to it, ' OR '1'='1 is just more query. The universal fix is the same everywhere: keep code and data separate. Never build the command by gluing strings; pass the input as a bound parameter the interpreter treats only as a value. This is the same discipline you learned for databases (Volume 7) and validation (Part II); here is the WRONG-then-RIGHT for each flavor.

// SQL injection — WRONG: string-concatenating user input into the query text.
const email = req.query.email;
db.query("SELECT * FROM users WHERE email = '" + email + "'");
// Attacker sends  email = ' OR '1'='1   → returns EVERY row.
// Or  email = x'; DROP TABLE users; --  → destroys the table.

// SQL injection — RIGHT: parameterize. The driver sends SQL and data on separate rails.
db.query("SELECT * FROM users WHERE email = ?", [email]);
// The input can ONLY ever be a value, never parsed as SQL. (Volume 7, Databases.)
// NoSQL injection — WRONG: passing a raw body field straight in as a query operand.
db.users.findOne({ email: req.body.email, password: req.body.password });
// Attacker sends JSON  { "password": { "$gt": "" } }  → matches ANY password.
// This is type-confusion: you expected a string, they sent an operator object.

// NoSQL injection — RIGHT: validate the TYPE first (Part II), then query with scalars.
const email = String(req.body.email);
const user = await db.users.findOne({ email });      // email is provably a string
// ...then verify the password HASH in application code (Part IV) — never in the query.

// Command injection — WRONG: interpolating input into a shell string.
exec(`convert ${req.query.file} out.png`);
// file = "x.png; rm -rf /"  → the shell runs rm -rf / too.

// Command injection — RIGHT: pass arguments as an array; no shell parses the input.
execFile("convert", [req.query.file, "out.png"]);   // input is one argument, never code

The through-line: sanitizing or "escaping" input by hand is a losing game (attackers out-creative your denylist — see validation, Part II); parameterization removes the parser from the equation entirely. There is no clever ' that breaks out of a bound parameter, because the input never enters the query language at all.

Excessive data exposure and mass assignment — guard both directions

Two of the most common API leaks are mirror images of each other, and both come from blindly trusting the shape of an object instead of controlling it field by field.

Excessive data exposure is leaking on the way out: you serialize a database row straight to JSON and ship the whole thing — including passwordHash, isAdmin, internal notes, another user's email. The client "only shows a few fields," but the API returned everything, and it's all sitting in the response for anyone with dev tools. The fix is to return only the fields this endpoint needs, via an explicit allowlist (a DTO / serializer), never res.json(userRowFromDb). This is also good REST hygiene (Part II): your response body is a contract, not a database dump.

// Excessive data exposure — WRONG: shipping the raw DB row.
const user = await db.users.findById(id);
res.json(user); // leaks passwordHash, isAdmin, tokenSecret, internalNotes...

// RIGHT: serialize an explicit, minimal public view — an allowlist of fields OUT.
res.json({ id: user.id, name: user.name, avatarUrl: user.avatarUrl });

Mass assignment (also called broken object-property authorization, or auto-binding) is the same mistake on the way in: you take the request body and hand it wholesale to your ORM — db.users.update(id, req.body) — so a caller who adds "isAdmin": true or "role": "admin" or "accountBalance": 999999 to their profile-update sets fields they should never control. The fix is the exact inverse of the exposure fix: allowlist the fields coming in — copy only the properties the client is permitted to set, and drop everything else.

Run the mass-assignment defense against a malicious body that tries to escalate privileges:

TypeScript — Try it Yourself
Loading editor…
Type check + Console

The attacker's isAdmin, role, and accountBalance never survive the pickAllowed gate, so they can never reach db.update. This is the same allowlist over denylist / default-deny principle from validation (Part II), applied to object properties: enumerate what's permitted, and everything unforeseen is refused by default. The alternative — trying to strip known-bad fields — always misses the field you didn't think of.

Security misconfiguration and SSRF

Two more risks come not from missing checks but from unsafe settings and unsafe outbound requests.

Security misconfiguration is the catch-all for insecure defaults left in place: debug/verbose mode on in production (leaking stack traces and internal paths — see error handling, Part II), default credentials never changed, an overly permissive CORS policy (Access-Control-Allow-Origin: * on authenticated endpoints — the subject of the next chapter), missing security headers, directory listing enabled, cloud storage buckets left public. Each is a setting, not a code path, which is why they slip through code review and need a hardening checklist and automated config scanning instead. The governing principle is fail securely: when something goes wrong, default to closed — deny access, hide detail, log the incident — never to open.

SSRF (Server-Side Request Forgery) is subtler and increasingly common. Your server accepts a URL from the user — "import from this URL," "set your avatar from this image link," a webhook target — and fetches it. An attacker supplies a URL pointing not at the public internet but at your internal network: http://169.254.169.254/ (the cloud metadata endpoint that hands out credentials), http://localhost:6379 (your Redis), or an internal admin service. Your server, sitting inside the trusted network, makes the request the attacker couldn't, and returns the loot. The defense is to treat any URL the user gives you as hostile: validate against an allowlist of permitted hosts/schemes, block requests to private/loopback/link-local IP ranges, and disable automatic redirect-following (a public URL can 302 you to an internal one). Never fetch an arbitrary user-supplied URL from a server that can see things the user can't.

Technique — the confused deputy SSRF is a textbook confused-deputy attack: your server is a trusted "deputy" with network access the attacker lacks, and the attacker tricks it into using that access on their behalf. The same shape appears in CSRF (next chapter), where the deputy is the user's browser. The general defense is identical: a privileged actor must independently verify that the requested action is legitimate for the real requester, not just execute whatever it's handed.

Rate limiting and API inventory

The last two risks are about scale and hygiene rather than a single crafted request.

Unrestricted resource consumption (lack of rate limiting) means an attacker can call an endpoint as fast as they like: brute-forcing passwords or OTPs, scraping your entire database one page at a time, or simply exhausting your CPU, database connections, and cloud bill (a "denial of wallet" attack). We build the defense in detail in Part VI — rate limiting and throttling, plus quotas and pagination caps — but flag it here as a security control, not just a performance one: an unthrottled login endpoint is a broken-authentication vulnerability waiting to be brute-forced.

Improper inventory management is the risk you never see coming, because it's about the endpoints you forgot: the /v1/ API you "replaced" but left running (still vulnerable, still unpatched), the staging. host exposed to the internet, the debug endpoint that skips auth "temporarily" two years ago, the deprecated field that still returns sensitive data. You cannot secure what you don't know exists. The defense is discipline: maintain an accurate inventory of every endpoint and version, document them (OpenAPI — the last chapter of this Part), and actively retire and shut down what you no longer support. Old code doesn't get safer with age; it gets more dangerous, because everyone stops looking at it.

The principles underneath every defense

Step back from the list, and the same handful of principles generate every defense above. Internalize these and you can reason about attacks you've never seen:

  • Never trust input. Every byte from the client — body, query, header, URL param, uploaded file, even a URL you're asked to fetch — is potentially hostile. Validate and parse at the edge (Chapter 1, Part II); this single reflex defeats injection, type-confusion, and mass assignment.
  • Least privilege. Give every user, token, and service the minimum access its job needs, and nothing more (Part IV). The blast radius of any compromise is exactly the privileges you granted — so grant narrowly. A leaked read-only token is a bad day; a leaked *-scope admin token is a catastrophe.
  • Defense in depth. Never rely on a single control. Validate at the edge and constrain at the database; check authz in middleware and at the object; use UUIDs and ownership checks. When (not if) one layer fails, the next one holds.
  • Fail securely. When something breaks — an error, a missing check, an unknown case — default to denied and quiet, never allowed and chatty. Deny by default (Part IV); on the unexpected, close the door.
  • Don't leak errors. An exception message is a gift to an attacker: stack traces reveal your framework and file paths, raw DB errors reveal your schema, "user not found" vs "wrong password" reveals which accounts exist. Return generic, safe messages to the client; log the detail internally (error handling, Part II).

Where this comes from These aren't trends — they're the oldest results in security engineering. In 1975, Jerome Saltzer and Michael Schroeder published The Protection of Information in Computer Systems, naming principles we still cite verbatim: least privilege, fail-safe defaults (deny by default), economy of mechanism (keep it simple enough to audit), and complete mediation (check every access, every time — which is exactly why BOLA happens when you don't). Half a century later, the OWASP Top 10 is largely a list of what happens when teams forget them. The attacks change costume; the principles that stop them do not.

Common Mistakes

  • Checking authentication but not object-level authorization. "They're logged in" is not "they own this record." BOLA/IDOR is the #1 API risk precisely because this check is so easy to skip. Authorize this caller against this object, every access.
  • Binding the request body straight to a DB model. db.update(id, req.body) lets a caller set isAdmin. Allowlist the fields in.
  • Returning the raw database row. Leaks passwordHash, internal flags, other users' data. Allowlist the fields out (a DTO/serializer).
  • Concatenating input into SQL/NoSQL/shell. The root of injection. Parameterize queries; pass shell args as arrays; never glue strings.
  • Fetching arbitrary user-supplied URLs. SSRF into your own network and cloud metadata. Allowlist hosts, block private ranges, don't follow redirects.
  • Leaving debug mode / verbose errors / defaults on in production. Misconfiguration hands attackers a map. Harden config; return generic errors; scan settings.
  • No rate limit on auth and expensive endpoints. Invites brute force, scraping, and cost-based DoS. Throttle (Part VI) — it's a security control.
  • Forgotten endpoints and old API versions still live. You can't secure what you don't know runs. Inventory, document, and retire.
  • Security by obscurity as the only defense. Hidden UI buttons, unguessable URLs, "no one will find it." Obscurity can add to defense in depth; it can never be the defense.

Think about it — trace one request A user sends POST /api/teams/17/invoices/8842/refund with body { "amount": 500, "approvedBy": "u_admin", "internalMemo": "x" }. Name every distinct attack this single request could be probing, and the defense for each. Start: is 17 the caller's team (BOLA/tenant isolation)? does invoice 8842 belong to team 17 (BOLA)? can this caller issue refunds (authz)? is amount a positive number within the invoice total (validation)? does binding approvedBy from the body let them forge an approver (mass assignment)? will the response echo internalMemo or other invoices' data (excessive exposure)? can they replay this a thousand times a second (rate limiting)? Notice that "one endpoint" is really a stack of independent checks — and missing any single one is the breach.

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Warm-up
What are some of the top API security risks you'd design against?
Model answer — Lead with BOLA/IDOR (broken object-level authorization) — the #1 risk: acting on a resource by ID without checking the caller owns it. Then broken authentication, injection (SQL/NoSQL/command), excessive data exposure and mass assignment (leaking or accepting fields you shouldn't), security misconfiguration, SSRF, lack of rate limiting, and forgotten/old endpoints (improper inventory). The framing that impresses: nearly all of them are a missing check or trusted input that should have been treated as hostile — so the meta-defense is "never trust input, authorize every object access, and default deny."
Intermediate
What is IDOR / mass assignment, and how do you fix each?
Model answer — IDOR (BOLA): a handler loads a resource by a user-supplied ID and acts on it having checked only that the caller is authenticated, not that they own this object — so incrementing the ID reads/deletes other users' data. Fix: after loading the resource, verify object-level authorization (resource.ownerId === caller.id || isAdmin), on every access; consider returning 404 instead of 403 so you don't confirm the resource exists. Mass assignment: binding the request body straight to a DB model (update(id, req.body)) lets a caller set fields they shouldn't, like isAdmin: true. Fix: allowlist — copy only the fields the client is permitted to set, and drop the rest (default deny). They're the input-side and authorization-side of "don't trust the shape of what the client sends."
Advanced
How do you prevent injection, and why is parameterization better than escaping/sanitizing input?
Model answer — Injection happens when user input is concatenated into a string another interpreter parses (SQL, a NoSQL query object, a shell command). The robust fix is parameterization / prepared statements: send the command and the data on separate channels so the input is never parsed as code — it can only ever be a value. Escaping or sanitizing by hand is a denylist you'll never complete: attackers find the encoding, quote, or Unicode trick you missed, whereas a bound parameter has no escape sequence because the input doesn't enter the query language at all. Supporting layers: validate/parse input types at the edge (a { $gt: "" } object where a string was expected is NoSQL injection caught by type-checking), pass shell arguments as arrays rather than a shell string, and apply least privilege to the DB account so even a successful injection is limited. Defense in depth, with parameterization as the primary control.
Company lens — how the same topic gets asked
micro1the AI screen tends to probe the fundamentals crisply — "name the top API risks," "what's IDOR and how do you fix it," "how do you stop SQL injection" — wanting the object-level check and parameterization named specifically, not a vague "add security."
Googleexpects you to reason from principles — trust boundaries, complete mediation, least privilege — and to think about authorization at scale (object- and relationship-level), not just to recite a checklist.
Metaproduct-security and privacy loom large — "who can access this object and why," excessive data exposure in responses, and abuse/rate-limiting on user-facing endpoints; expect a real endpoint and "walk me through everything that could go wrong."
Amazonthink least privilege and blast radius as defaults (IAM-flavored), plus "Dive Deep" on failure modes — what a compromised token/service can reach, how you scope it down, tenant isolation, and safe-by-default configuration.

Hands-on Build a secure "update my profile" handler that combines both defenses from this chapter. It must (1) load the profile by id and enforce object-level authz — a caller may only update their own profile (BOLA fix); (2) allowlist the incoming fields so a body containing role/isAdmin can't escalate privileges (mass-assignment fix); and (3) return only a safe public view, never the raw record (excessive-exposure fix). Fill in the TODOs, then run it against a legitimate self-update and against a malicious cross-user + privilege-escalation attempt, and confirm what's blocked.

TypeScript — Try it Yourself
Loading editor…
Type check + Console

Summary

  • Your API is the attack surface — every endpoint, parameter, and field, spoken to directly by attackers with scripts. Security is a property of every handler, not a feature you add at the end; the weakest door is the whole breach.
  • The OWASP API Security Top 10 maps the risks that actually cause breaches. Most reduce to a missing check or trusted input that should have been hostile.
  • BOLA/IDOR is #1: authorize this caller against this object on every access, not just "are they logged in." Injection: parameterize, never concatenate — code and data on separate rails (Vol 7, Part II). Mass assignment: allowlist fields in; excessive exposure: allowlist fields out. SSRF: never fetch arbitrary user URLs into your network. Plus broken auth (Part IV), misconfiguration, rate limiting (Part VI), and retiring forgotten endpoints.
  • The generative principles: never trust input, least privilege, defense in depth, fail securely, don't leak errors — half-century-old ideas (Saltzer & Schroeder) that still defeat attacks you've never seen.

Next, we zoom in on the browser's own security model and the headers that govern it: CORS, CSRF, and security headers — why the same-origin policy exists, how cross-site request forgery tricks a user's browser into acting for them, and the response headers that lock a web API down.

Editorial reflection Security is the one part of backend work where the person you're building against is not imagined — they're real, awake somewhere, and actively hostile to your carelessness. That can feel adversarial and grim. But flip it: every check you write is an act of protection for someone who will never know your name — the user whose messages stay private, whose account isn't drained, whose data isn't sold because you took thirty seconds to verify ownership. Attackers automate their patience; the only counter is to make defense a habit rather than a heroics. Not a security sprint before launch, but a small, boring, faithful check at every door, every time. That quiet diligence — treating a stranger's data as something you were entrusted with — is not paranoia. It's the whole job.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.