CYART // SCHEMA · REQUEST-PATH7 NODES · 6 LINKSLOOKUP1HTTPS234READ / WRITE5HIT FIRSTACTOR · User — browser / appACTOR01Userbrowser / appEDGE · DNS — resolves nameEDGE02DNSresolves nameEDGE · CDN / WAF — TLS ends hereEDGE03CDN / WAFTLS ends hereEDGE · Load balancer — L7, health checksEDGE04Load balancerL7, health checksSERVICE · API service — authn + authzSERVICE05API serviceauthn + authzDATA · Database — primary + replicasDATA06Databaseprimary + replicasDATA · Cache — short TTLDATA07Cacheshort TTL

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.

● Must know for interviewsFREELesson·30 min read·Intermediate → Advanced
APIOWASP API Top 10BOLAgatewayschema validation

01The OWASP API Top 10, ranked by how often it hurts

✓What you will learn
  • 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.

RiskWhat it looks likeCore fix
API1 Broken object level authorisation (BOLA)GET /invoices/1043 returns another tenant's invoice when you change the IDScope every lookup by the caller: WHERE id = $1 AND tenant_id = $2
API2 Broken authenticationTokens without expiry, no rate limit on login, JWT alg not pinnedStandard 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 capPagination 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 worksDeny by default, role checks per route, admin APIs on a separate surface
API6 Unrestricted access to sensitive business flowsBots buy all the stock or mass-create accountsFlow-level limits, bot detection, step-up checks on sensitive flows
API7 Server side request forgeryWebhook URL or image import fetches 169.254.169.254Egress proxy with allow-list, block private ranges, IMDSv2
API8 Security misconfigurationVerbose errors, permissive CORS, missing TLS on internal hopsHardened gateway and service templates, config scanning
API9 Improper inventory managementOld /v1 still live without the fixes in /v2; forgotten staging APIAPI catalogue from gateway and traffic, retire old versions, spec-first
API10 Unsafe consumption of APIsTrusting a partner's response and passing it into SQL or a redirectValidate third-party responses like user input; timeouts and TLS
◎Interview radar

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.

Schema in, DTO out, scoped query in betweents
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.
!Mass assignment hides in ORMs

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.

▭On the whiteboard · Limits every API should have5 points
01
size
body cap per route (for example 1 MB JSON, 25 MB upload), max array length, max string length in schema
02
page
default 50, max 200; cursor pagination, no unbounded offset
03
time
server timeout below gateway timeout; query timeout in the database
04
rate
per API key and per user, plus a global budget per expensive endpoint
05
depth
GraphQL: max depth, max complexity score, persisted queries in production

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.

FigWhere API controls sit on the request path
CYART // SCHEMA · REQUEST-PATH7 NODES · 6 LINKSLOOKUP1HTTPS234READ / WRITE5HIT FIRSTACTOR · User — browser / appACTOR01Userbrowser / appEDGE · DNS — resolves nameEDGE02DNSresolves nameEDGE · CDN / WAF — TLS ends hereEDGE03CDN / WAFTLS ends hereEDGE · Load balancer — L7, health checksEDGE04Load balancerL7, health checksSERVICE · API service — authn + authzSERVICE05API serviceauthn + authzDATA · Database — primary + replicasDATA06Databaseprimary + replicasDATA · Cache — short TTLDATA07Cacheshort TTL
  • ACTOR
  • EDGE
  • SERVICE
  • DATA
  • Live flow
  • Lookup / async
//The edge terminates TLS and absorbs floods. The gateway authenticates, enforces schema, size and rate limits and logs. The service does object, property and function authorisation against its own data, because only it knows who owns what.

04Gateway versus service: who checks what

ControlGatewayService
TLS, mTLS to upstreamYesVerifies mesh identity
Token validation (signature, expiry, audience)YesRe-checks claims it relies on, or trusts a signed internal token
Rate limits and quotasYes, per key and per routePer-tenant cost budgets for expensive operations
Schema and size validationCoarse: content type, size, OpenAPI shapeStrict: business rules, .strict() schemas
Function level authz (BFLA)Coarse: scopes per routeFine: role and attribute checks
Object level authz (BOLA)No: it cannot know who owns invoice 1043Yes, always
Inventory and loggingYes: the catalogue and audit trailStructured audit events for sensitive actions
Spec-first validation at the gatewayyaml
# 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" }
iDefence in depth, not delegation

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.

“How to say it in the room

"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."

Test yourself

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.

Finished this one?
Tick it and it shows as done on the roadmap and the course.