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.
🎯 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
📚 Before you start
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:
// 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
| 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.
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:
// 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.
4Mass Assignment
An endpoint that blindly maps the request body onto a database row lets a client set fields it should never control:
// 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:
// 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):
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
- Checking authentication and calling it done. BOLA (section 3) exists specifically because authentication alone says nothing about whether this caller may touch this resource.
- 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.
- Serializing the raw database row. Excessive data exposure (section 5) is what happens when "just return the object" substitutes for an explicit response shape.
- 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.
- 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🧠 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
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.
- 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.
- 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.
- 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.
- 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.
- 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.