# API Security

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.

## 1. Authentication 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).

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

| Mechanism | Typical use | Revocation | Granularity |
|---|---|---|---|
| **API key** | Server-to-server, no per-user identity needed | Rotate/revoke the whole key | Usually one key = one set of permissions for the whole integration |
| **Bearer token (JWT)** | Per-user identity, short-lived | Expires naturally; hard to revoke early without a blocklist (see `security/security/05-access_and_refresh_tokens`) | Can carry per-user claims/scopes |
| **OAuth2 access token** | Third-party app acting on a user's behalf, with the user's explicit consent | Standard revocation endpoint | Scoped 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.

## 3. Broken 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](diagrams/01-api-authz-checkpoints.svg)

## 4. Mass 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.

## 5. Excessive 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.

## 6. Scopes: 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.

## 7. HTTPS 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

```
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
```
