Engineering Notes
Home/Backend/API Design/API Security
Backend · API Design

API Security

Most API breaches aren't exotic: missing object-level checks, mass assignment and over-sharing responses. Watch a BOLA attack enumerate other users' invoices, then fix it, and close the mass-assignment and data-exposure holes.

⚙️ Backend● Intermediate⏱ 6 min read▶ 2 interactive🛠️ 3 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • Authentication vs authorization at the API layer
  • API keys vs bearer tokens
  • BOLA, mass assignment and excessive data exposure
  • Scopes and HTTPS everywhere

This topic assumes familiarity with security/security/01-authentication_vs_authorization, 02-jwt, and 03-oauth — those cover the auth mechanisms themselves (what a JWT is, how OAuth2 flows work) in depth and are not repeated here. This topic is about the API-layer design discipline those mechanisms plug into: how endpoints enforce authorization once identity is established, and the attack surface specific to programmatic (not browser-form) APIs.

1Authentication vs. Authorization, Restated at the API Layer

Authentication answers "who is this caller" — resolved once, typically at request entry, from an API key, Bearer token, or session. Authorization answers "is this caller allowed to do this specific thing" — checked per-operation, often per-resource, and is where most real API vulnerabilities live, not in the authentication step itself:

ts
// Authentication: resolves ONE identity from the request
async function authenticate(req: Request): Promise<User> {
  const token = req.headers.authorization?.replace("Bearer ", "");
  return verifyJwt(token); // throws if invalid/expired — see security/security/02-jwt
}

// Authorization: a SEPARATE check, per operation, per resource
async function authorizeOrderAccess(user: User, orderId: number): Promise<void> {
  const order = await db.orders.findById(orderId);
  if (order.customerId !== user.id && !user.roles.includes("admin")) {
    throw new ForbiddenError(); // authenticated, but not authorized for THIS resource
  }
}

A request can pass authentication perfectly (a completely valid, unexpired token) and still need to be rejected — that rejection is authorization's job, and skipping it is the single most common category of real API vulnerability (section 3).

2API Keys vs. Bearer Tokens (JWT/OAuth2) — When Each Fits

MechanismTypical useRevocationGranularity
API keyServer-to-server, no per-user identity neededRotate/revoke the whole keyUsually one key = one set of permissions for the whole integration
Bearer token (JWT)Per-user identity, short-livedExpires naturally; hard to revoke early without a blocklist (see security/security/05-access_and_refresh_tokens)Can carry per-user claims/scopes
OAuth2 access tokenThird-party app acting on a user's behalf, with the user's explicit consentStandard revocation endpointScoped to exactly what the user consented to

API keys identify an integration, not a person — they're the wrong tool the moment you need to know which individual user did something, which is precisely where Bearer tokens with per-user claims take over.

3Broken Object-Level Authorization (BOLA) — the #1 Real-World API Vulnerability

BOLA (also called IDOR — Insecure Direct Object Reference) is what happens when an endpoint authenticates the caller correctly but never checks whether this caller may access this specific resource:

ts
// VULNERABLE: any authenticated user can read ANY invoice by guessing/incrementing the id
app.get("/invoices/:id", authenticate, async (req, res) => {
  const invoice = await db.invoices.findById(req.params.id);
  res.json(invoice); // no check that invoice.customerId === req.user.id
});

// FIXED: authorization check scoped to the resource, not just "is logged in"
app.get("/invoices/:id", authenticate, async (req, res) => {
  const invoice = await db.invoices.findById(req.params.id);
  if (invoice.customerId !== req.user.id) throw new ForbiddenError();
  res.json(invoice);
});

This is consistently ranked #1 in the OWASP API Security Top 10 precisely because it's easy to write (it requires no special attack tooling — sequential IDs plus a valid-but-unrelated token are enough) and easy to miss in code review, since the endpoint "looks" secure — it does check authenticate, just not the object-level scope.

A request passes token and scope checks, then an object-level ownership check, then an output DTO; skipping each step produces broken authentication, BOLA, or mass assignment and data exposure
A request passes token and scope checks, then an object-level ownership check, then an output DTO; skipping each step produces broken authentication, BOLA, or mass assignment and data exposure

4Mass Assignment

An endpoint that blindly maps the request body onto a database row lets a client set fields it should never control:

ts
// VULNERABLE: whatever fields the client sends get written, including ones never intended as writable
app.patch("/users/:id", authenticate, async (req, res) => {
  await db.users.update(req.params.id, req.body); // req.body might include { role: "admin" }
});

// FIXED: explicit allowlist of client-writable fields
const WRITABLE_FIELDS = ["displayName", "bio", "avatarUrl"] as const;
app.patch("/users/:id", authenticate, async (req, res) => {
  const patch = Object.fromEntries(
    Object.entries(req.body).filter(([k]) => WRITABLE_FIELDS.includes(k as any))
  );
  await db.users.update(req.params.id, patch);
});

This connects directly to backend/api-design/08-request_validation: a validation schema that only describes shape but doesn't restrict which fields are writable-by-role still leaves mass assignment open if the same schema is used for both a normal user's PATCH and an admin's PATCH.

5Excessive Data Exposure

The inverse problem: an endpoint returns an entire internal object (because it's convenient to res.json(dbRow)) and relies on the client to only display the fields it needs, trusting a UI layer that an attacker calling the API directly simply ignores:

ts
// VULNERABLE: returns password_hash, internal_notes, everything — "the UI just doesn't show it"
res.json(userRow);

// FIXED: explicit response shape, only ever the fields meant to be public
res.json({ id: userRow.id, displayName: userRow.displayName, avatarUrl: userRow.avatarUrl });

An API response is a security boundary in its own right — "the frontend won't render that field" is not a control, since any HTTP client can read the raw response body directly.

6Scopes: Narrowing What a Token Can Do

Beyond "is this token valid," a well-designed API checks what this specific token is allowed to do, via scopes carried in the token (typically JWT claims for OAuth2 tokens):

ts
function requireScope(scope: string) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user.scopes.includes(scope)) throw new ForbiddenError(`missing scope: ${scope}`);
    next();
  };
}

app.delete("/orders/:id", authenticate, requireScope("orders:delete"), handler);

This lets a single OAuth2-issued token be deliberately less powerful than "everything this user can do" — a read-only integration gets a token scoped to orders:read only, and even if that token leaks, the blast radius is bounded to reads.

7HTTPS Is Non-Negotiable, Not a Configuration Choice

Every API endpoint that accepts a Bearer token or API key must run exclusively over TLS — a token sent over plain HTTP is trivially interceptable by anything on the network path (see networking-web/fundamentals/09-tls). Production APIs should reject plain-HTTP requests outright (redirect or hard-fail) rather than "supporting both," because supporting both means a single misconfigured client silently leaks credentials in cleartext with no visible failure.

⚠️ Common Pitfalls

  1. Checking authentication and calling it done. BOLA (section 3) exists specifically because authentication alone says nothing about whether this caller may touch this resource.
  2. Using one shared PATCH/PUT schema for both privileged and unprivileged callers. Without an explicit writable-field allowlist per role, mass assignment (section 4) lets a normal user set fields meant only for admins.
  3. Serializing the raw database row. Excessive data exposure (section 5) is what happens when "just return the object" substitutes for an explicit response shape.
  4. Treating an API key as a substitute for per-user authorization. A key identifies an integration, not the human on the other end of it — code behind a key-authenticated endpoint that assumes "this caller is fully trusted" often can't distinguish legitimate use from abuse by a compromised downstream client.
  5. One token, unlimited scope, by default. Issuing every token full account-level access "because it's simpler" means a single leaked token compromises everything the user could ever do, instead of just what that integration needed.

📋 Quick Reference

text
Authentication  -> who is this caller (once, at entry)
Authorization   -> is this caller allowed to do THIS, to THIS resource (every operation)
BOLA/IDOR       -> #1 real API vuln: auth passes, object-level check is missing
Mass assignment -> blindly writing req.body onto a row; fix: explicit writable-field allowlist
Data exposure   -> returning the whole row; fix: explicit response shape
Scopes          -> narrow what a token can do beyond "is it valid"
API key         -> identifies an integration; Bearer/OAuth2 token -> identifies a user
TLS             -> mandatory for any token/key-bearing request, no plain-HTTP fallback

🧠 Key takeaways

  • Authenticate every request; authorize every object (BOLA is OWASP API #1).
  • API keys identify applications; bearer tokens (OAuth/JWT) carry user identity and scopes.
  • Prevent mass assignment with allow-listed input schemas; prevent data exposure with explicit response DTOs.
  • Least-privilege scopes, HTTPS only (HSTS), rate limits, and no secrets in URLs.

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1What are the most common API security risks?core▾

OWASP API Top 10: broken object-level authorization (IDOR), broken authentication, broken object property-level authorization (mass assignment, excessive data exposure), unrestricted resource consumption, broken function-level authorization, SSRF, misconfiguration, and unsafe consumption of third-party APIs.

Follow-ups interviewers ask
  • Number one? Broken object-level authorization.
Q2How do you secure an API end to end?core▾

TLS everywhere, strong authentication (OAuth2/OIDC, mTLS for services), authorization checks on every object and function, input validation, output filtering (no excess fields), rate limiting, logging/monitoring, secrets management, and dependency scanning.

Follow-ups interviewers ask
  • Gateway's role? Centralized auth, rate limiting and WAF, plus service-level checks.
Q3API keys vs OAuth tokens?senior▾

API keys identify a client application; they're simple but long-lived, often over-privileged, and not user-specific. OAuth tokens are scoped, short-lived, and can represent users. Use API keys for simple server-to-server access with rotation; OAuth for delegated user access.

Follow-ups interviewers ask
  • How do you store API keys? Hash them like passwords and show the full key only once.
Q4How do you prevent excessive data exposure?core▾

Return explicit response DTOs with only needed fields, never serialize entities directly, and filter by the caller's permissions server-side rather than relying on the client to hide fields.

Follow-ups interviewers ask
  • Classic bug? Returning a user entity with password hash or internal flags.
Q5What is SSRF and how do you prevent it in APIs?core▾

Server-Side Request Forgery: the API fetches a user-supplied URL, letting attackers reach internal services or cloud metadata. Prevent with allowlists of hosts, blocking private/link-local IPs after DNS resolution, disabling redirects, and network egress controls.

Follow-ups interviewers ask
  • Why resolve DNS first? A public hostname can resolve to an internal IP (DNS rebinding).

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.

✅ Check yourself

Answer the questions — feedback is instant.

1. GET /orders/123 returns another user's order. Vulnerability?
Object-level authorization.
2. Do random UUIDs fix BOLA?
Defense in depth, not a fix.
3. Spreading req.body into an ORM update causes…
Allow-list fields.
4. Hiding passwordHash in the frontend but returning it in JSON is…
Shape responses on the server.