System Design Concepts · API Design
API security: three families of mistakes, not a top ten list
Real breaches come from missing checks, leaked fields and trusted input.
The route nobody gated
- The client sends a request with a role: user token over TLS to an endpoint guarded by four checks: authN, route role, object owner, field scope. Those four checks are what secure means for an endpoint. CORS does nothing for securing an API endpoint.
- TLS is fine, and the request passes all four checks, so the server responds back with a 200. Breaches never happen due to broken crypto, but almost always due to a missing check.
- The route table lists GET /posts, POST /posts and GET /admin/users, and all three check authN. Only the admin route has no role check, because that route was added last and developer forgets to add the check.
- The same role: user token calls GET /admin/users. It passes authN, nothing stops it, and the respond back with 200, and every user's email, 1,204 of them. No exploit and no tool, just a URL guessed from the admin menu.
- One level down, GET /users/124/orders checks the caller's identity and role, but don't checks who owns the order. A normal user passes the role check, so user 124's orders come back to a caller who is not user 124 - this is also covered in a separate concept.
- The admin route is API5 from OWASP, broken function level authorization. The orders route is API1, broken object level authorization. The fix for API5 is a default-deny route policy: the server checks every route unless that route is listed as open.
The model is not the API
- The server's User model has six columns: id, name, email, password_hash, role, internal_notes. But only three of those six columns should ever leave the server.
- GET /users/123 returns the whole model. Client got all six values including password_hash and internal_notes. Even if the client never display those fields.
- A response DTO now sits between the model and the client: id, name, email. Only those three values reach the client. The API has its own schema/shape, instead of returning the whole model.
- POST /users carries name and email. User.create(req.body) copies the body onto the model, filling both columns, and the server respond with 201.
- The same body now carries role: admin. The server writes that value straight onto the model's role column without any checks. Anyone who knows the model's field names can set them.
- An allow list now sets the fields a request may write: name, email. The same signup body arrives with role, the server saves name and email, it ignores role, and creates the user with the default role, user.
- These two mistakes : over-returning in the server's response, and over-accepting in the incoming request, OWASP covers both under: API3:2023, broken object property level authorization.
Guardrails are the third family
- Nothing limits how fast one client can call the API service, so the request rate rises to 2400 req/s and p99 latency reaches 9.8 s. Unrestricted resource consumption, API4 from OWASP, is an outage before it is a hack.
- A rate limiter closes that gap and rejects anything past 1000 requests a second with 429, so the rate drops to 1000. The limit is there for capacity and fairness.
- Nothing checks the request body at the edge, so the service parses a 10 MB string and a nested array in full. A schema now validates request shape, size and type on every field, every time, so the service refuses the next oversized body with a 400.
- A normal user calls GET /admin/users and gets a 200, but the service records nothing about the call. Logging closes that gap, and the service now writes three log lines, each with time, user, method, path and status, but not the secrets.
- A scanner tries every path on the service: /beta answers 404, /v2 answers 200, and the unpatched /v1 route still answers 200 through an open gap. Improper inventory, API9-OWASP: hidden is not secured, and forgotten is not gone.
- The /v1 route is retired, and a Sunset header indicates its last day, 31 Dec 2026. That closes the last gap, so all five guardrails are now in place: HTTPS, limit, validate, log, retire.
© LearnThatStack - diagrams may not be republished without permission.
Real breaches rarely come from weak cryptography, but usually from a missed check. This request carries a token for the user role, and TLS protects the request on the wire. The request is validated against a few checks : authentication and the route role first. The object owner and the field scope come next, and these checks combined secure the endpoint.
TLS protects the request while it travels. The request passes all four checks and server responds with a 200. A breach is usually a missed check on the request.
Authentication only says who the caller is. All three routes pass that check. But GET /admin/users needs a role check too, and it is missing.
The same token carries the user role, but the admin route never checks it. The server returns 200 with 1,204 email addresses, no exploit needed. OWASP calls this broken function level authorization, API5.
The endpoint GET /users/124/orders checks authentication and role, but not ownership. The role check passes any normal user, so any other user can read user 124's orders. That missing check is broken object level authorization, OWASP API1.
A default-deny policy fixes the function level gap. Every route stays closed unless it is listed as open. So a route nobody remembered is a route nobody can reach.
The user model holds six columns: id, name, email, password_hash, role and internal_notes. Three of those are safe to show. The other three are meant to be private for the system.
The handler for GET /users/123 returns the whole model, all six values, including password_hash and internal_notes. The client may never display them, but anyone who opens the network tab can read them.
The response DTO carries id, name, and email, so only those three reach the client. A DTO, a data transfer object, is the API's shape, separate from the database table. Filter on the server, not the client.
A POST /users request carries name and email. The handler calls User.create(req.body), which binds it onto the model and returns 201. Returning the whole user is the same convenience in reverse.
The request body now sets role to admin. No code checked that field, and the model accepts anything matching a column, so it created an admin account. That is mass assignment.
The server now has an allow list: name and email. A new signup arrives, and the server accept both email and mane and ignores role because role is not on the allow list. So the user is created with the default role. List what is allowed to be written. A deny list of dangerous fields can miss the next column you add.
The API returns more fields than the caller needs, and accepts more than it should. OWASP merged excessive data exposure and mass assignment into one, API3:2023, broken object property level authorization. The model is not the API.
Nothing limits one client's traffic, so the rate hits 2400 req/s and p99 latency reaches 9.8 seconds. Unrestricted resource consumption, OWASP-API4, is a cost and an outage before it is ever a hack.
A rate limiter rejects anything above 1000 requests a second and returns 429. The limit is about capacity and fairness between callers.
The service parses the whole request body before any handler runs. A 10 MB string and a nested array reach the parser first, so the parser can be attacked too. A schema closes that gap, so the service rejects the next oversized body with a 400. Check shape, size and type on the way in, every field, every time.
A normal user calls GET /admin/users and the server answers 200. The service log records nothing, so nobody will ever know about that call. Log every request: the time, the user, the method, the path and the status. Logging turns a breach into an incident you can investigate, so log who called what. But don't log the secrets the caller sent.
A scanner sends requests to the service. The path /beta answers 404, /v2 answers 200, and the deprecated /v1 still answers 200. Nobody documented /v1 for two years, and the service never stopped running it. Improper inventory, OWASP-API9: hiding an endpoint does not secure it, and forgetting an endpoint does not remove it.
A Sunset header on the /v1 route indicates the route's last day, 31 Dec 2026. Retiring means ending a version without breaking the clients that still use that version.
Three families cover the OWASP top ten. Access control - a check on every route and a check on every object. Trust - a declared shape for every response and every request. Guardrails - a limit, a schema, and a log, plus a sunset date for every old version.
In interviews · 4 questions
Related Questions
- 01
- 02