API security
The OWASP API Security Top 10 in practice: object and function level authorisation, mass assignment, resource limits, SSRF and inventory, plus what belongs at the gateway versus in the service.
01The OWASP API Top 10, ranked by how often it hurts
- Explain BOLA, BOPLA and BFLA and why scanners rarely find them
- Stop mass assignment with explicit input schemas
- Set resource limits that defend both availability and your cloud bill
- Split controls between the gateway and the service correctly
APIs strip away the UI that used to hide object IDs and admin functions. Every endpoint is callable directly with any parameter. The OWASP API Security Top 10 (2023) reflects that: most of it is authorisation and inventory, not injection.
| Risk | What it looks like | Core fix |
|---|---|---|
| API1 Broken object level authorisation (BOLA) | GET /invoices/1043 returns another tenant's invoice when you change the ID | Scope every lookup by the caller: WHERE id = $1 AND tenant_id = $2 |
| API2 Broken authentication | Tokens without expiry, no rate limit on login, JWT alg not pinned | Standard IdP, short tokens, pinned algorithms, credential stuffing defences |
| API3 Broken object property level authorisation (BOPLA) | Response leaks passwordHash; request sets role: "admin" (mass assignment) | Explicit response DTOs and input schemas with allow-listed fields |
| API4 Unrestricted resource consumption | ?limit=1000000, huge uploads, expensive search with no cap | Pagination caps, body size limits, timeouts, per-key quotas and cost budgets |
| API5 Broken function level authorisation (BFLA) | Normal user calls DELETE /admin/users/7 and it works | Deny by default, role checks per route, admin APIs on a separate surface |
| API6 Unrestricted access to sensitive business flows | Bots buy all the stock or mass-create accounts | Flow-level limits, bot detection, step-up checks on sensitive flows |
| API7 Server side request forgery | Webhook URL or image import fetches 169.254.169.254 | Egress proxy with allow-list, block private ranges, IMDSv2 |
| API8 Security misconfiguration | Verbose errors, permissive CORS, missing TLS on internal hops | Hardened gateway and service templates, config scanning |
| API9 Improper inventory management | Old /v1 still live without the fixes in /v2; forgotten staging API | API catalogue from gateway and traffic, retire old versions, spec-first |
| API10 Unsafe consumption of APIs | Trusting a partner's response and passing it into SQL or a redirect | Validate third-party responses like user input; timeouts and TLS |
BOLA is the question you will get. Have one crisp example, the one-line fix (scope the query), and the class fix (a data-access layer that requires a tenant context, so an unscoped query will not compile or will not pass review).
02Object, property and function authorisation
Scanners find BOLA poorly because the response to someone else's ID looks exactly like a correct response. It is a logic bug, so the fix has to be structural: make an unscoped query hard to write.
import { z } from "zod";
// Input schema: only these fields may be set by a client. role, tenantId, balance are absent.
const UpdateProfile = z.object({
displayName: z.string().min(1).max(80),
timezone: z.string().max(64).optional(),
}).strict(); // unknown keys are rejected, not silently dropped
// Output DTO: only these fields ever leave the service.
const toPublicUser = (u: UserRow) => ({ id: u.id, displayName: u.displayName, timezone: u.timezone });
app.patch("/users/:id", requireAuth, async (req, res) => {
const input = UpdateProfile.parse(req.body); // BOPLA: mass assignment blocked
if (req.params.id !== req.user.id && !req.user.can("users:update:any")) {
return res.status(404).end(); // BOLA and BFLA: deny, do not confirm existence
}
const user = await users.update(
{ id: req.params.id, tenantId: req.user.tenantId }, // scoped by tenant, always
input,
);
if (!user) return res.status(404).end();
res.json(toPublicUser(user)); // BOPLA: no passwordHash, no internal flags
});- Random IDs are not authorisation. UUIDs make guessing harder but leak through URLs, logs and shared links. Check ownership anyway.
- 404 rather than 403 for objects the caller may not see, so you do not confirm they exist.
- Admin functions on a separate surface (different host or gateway route with its own policy) so a missing check on one route is not the only barrier.
- Test authz in CI: for each endpoint, call it as user A with user B's object and as a non-admin on admin routes. Expect 404 or 403.
User.update(req.body) or Object.assign(entity, req.body) passes every field the client sends. The bug appears months later when someone adds an isAdmin column. A .strict() input schema fails loudly instead.
03Resource consumption, SSRF and inventory
An unbounded endpoint is a denial of service you ship yourself, and in the cloud it is also a denial of wallet. Every endpoint needs a size, a time and a rate limit, and expensive ones need a cost budget.
SSRF reappears in APIs through webhooks, URL previews and file import. Route every server-initiated fetch through one egress proxy with an allow-list, and block link-local and private ranges there. Inventory is the quiet risk: you cannot protect an endpoint nobody knows exists. Generate the catalogue from gateway config and observed traffic, not from a wiki.
- ACTOR
- EDGE
- SERVICE
- DATA
- Live flow
- Lookup / async
04Gateway versus service: who checks what
| Control | Gateway | Service |
|---|---|---|
| TLS, mTLS to upstream | Yes | Verifies mesh identity |
| Token validation (signature, expiry, audience) | Yes | Re-checks claims it relies on, or trusts a signed internal token |
| Rate limits and quotas | Yes, per key and per route | Per-tenant cost budgets for expensive operations |
| Schema and size validation | Coarse: content type, size, OpenAPI shape | Strict: business rules, .strict() schemas |
| Function level authz (BFLA) | Coarse: scopes per route | Fine: role and attribute checks |
| Object level authz (BOLA) | No: it cannot know who owns invoice 1043 | Yes, always |
| Inventory and logging | Yes: the catalogue and audit trail | Structured audit events for sensitive actions |
# OpenAPI fragment the gateway enforces: unknown fields rejected, bounds explicit.
paths:
/v2/invoices:
get:
security: [{ oauth: ["invoices:read"] }]
parameters:
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
- { name: cursor, in: query, schema: { type: string, maxLength: 128 } }
components:
schemas:
InvoiceCreate:
type: object
additionalProperties: false
required: [customerId, lines]
properties:
customerId: { type: string, format: uuid }
lines:
type: array
maxItems: 100
items: { $ref: "#/components/schemas/InvoiceLine" }The gateway is a great place for uniform controls and a terrible place for business authorisation. If a service is reachable without the gateway (internal callers, a second ingress), every gateway-only control vanishes. Services must stand on their own for authz.
"The gateway does the uniform things: authenticate, enforce the OpenAPI schema, cap size and rate, and log to the catalogue. The service owns authorisation, because only it knows that invoice 1043 belongs to tenant 7. I'd add an authz test per endpoint in CI that calls it as the wrong user and expects a 404."
Which control can only be enforced reliably inside the service, not at the gateway?
Q1How do you find shadow APIs?+
Compare the gateway route table and OpenAPI specs with observed traffic from load balancer and mesh logs. Anything in traffic but not in a spec is shadow; anything in a spec with no owner is a zombie. Retire or register both.
Q2GraphQL changes what?+
One endpoint, so per-route controls disappear. Use depth and complexity limits, persisted queries in production, disable introspection publicly, and do authz in resolvers per field and object.
Q3API keys or OAuth for partners?+
OAuth client credentials with scopes and short tokens where you can. If you must use API keys, hash them at rest, scope them, show them once and support rotation without downtime.