API Security: OWASP Top 10

Standards every Code2b engineer follows when writing HTTP APIs. Based on the OWASP API Security Top 10 (2023). Auth is Clerk with no dev bypass. Handlers never trust the client for ownership, role, or field-level writes.

Rule zero: wherever user-controlled data enters the app, inspect that path ten times. IDs in the URL, JSON bodies, query params, headers, uploaded files, and third-party responses all count.


How to read this

Each item is meaning, a real request, then the fix. Auth is not authorization. Being logged in only proves who you are. Every object, field, and privileged function still needs a server-side check.

Check Question
Authentication Who is this caller?
Object auth (BOLA) Can they access this record?
Property auth Can they read or write this field?
Function auth (BFLA) Can they call this action?

Put those checks in the domain/service layer, not only in middleware. Middleware can require a session. It cannot know whether user A owns job 456.


API1: Broken Object Level Authorization (BOLA / IDOR)

Meaning. The caller changes an ID and reads or changes someone else's object. The API checked "logged in", not "owns this row".

GET /api/pipelines/111/leads/456
Authorization: Bearer <alice-token>

Alice swaps 456 for Bob's lead ID. If the handler loads by ID and skips the owner check, that is BOLA.

// Bad: any authenticated user can load any lead
lead, err := repo.GetLeadByID(ctx, leadID)
 
// Good: scope to the caller
lead, err := repo.GetLeadForUser(ctx, userID, leadID)
if errors.Is(err, domain.ErrNotFound) {
    return fiber.ErrNotFound // same 404 for missing and forbidden
}

Fix. Authorize the object on every request. Load by (user_id, id), or load then compare lead.UserID == caller.ID. Return 404, not 403, when the row exists but is not theirs. Do this in the service, not only the handler.


API2: Broken Authentication

Meaning. Login, tokens, or sessions are validated weakly, so an attacker impersonates a user or skips login.

POST /api/login
{"email":"[email protected]","password":"wrong"}

If a wrong password still returns a session, or /api/profile accepts a forged Bearer token, authentication is broken.

Fix.

Rule Detail
Validate every credential and token Signature, issuer, audience, expiry. Reject missing or malformed tokens.
Short-lived access tokens Refresh through a known flow. Revoke on logout.
Hash passwords with a slow KDF Argon2id or bcrypt. Never store plaintext or reversible "encryption".
Same response on login failure Do not reveal whether the email exists.
No auth bypass in any environment Clerk has no local skip. Test the service function or drive the browser.

Handlers read the verified identity from the auth middleware context. They never trust a user_id in the body or query string as "who I am".


API3: Broken Object Property Level Authorization

Meaning. The user may touch the object, but they can read or write fields they should not.

PATCH /api/users/123
{"name":"Hamid","is_admin":true,"credit_balance":99999}

Name is allowed. is_admin and credit_balance are not.

// Bad: decode the whole model from the client
var user domain.User
c.BodyParser(&user)
repo.Update(ctx, user)
 
// Good: bind only the allowed DTO
type UpdateProfileRequest struct {
    Name string `json:"name"`
}

Fix. Allowlist readable and writable fields per role. Bind a request DTO, never the persisted model. Mass-assignment of domain structs is how is_admin slips in. Extra JSON keys must be ignored, not merged.


API4: Unrestricted Resource Consumption

Meaning. Expensive work has no cap. Attackers burn CPU, memory, bandwidth, or third-party spend.

POST /api/enrichment/start
{"lead_ids":[...1000000 ids...],"enrichment_type":"full"}

Unbounded exports, unbounded fan-out, or missing pagination will take the box down or drain provider credits.

Fix.

Limit Typical cap
Request body size Fiber / proxy max body (small, explicit)
List page size Hard max, e.g. 100. Never "return all".
Fan-out / workers Bound with a semaphore (LLM_CONCURRENCY)
Timeouts Context deadline on every outbound call
Rate limit Per user and per IP on costly routes
Spend Pre-check wallet / quota before calling a paid provider

Reject over-limit input with 400 or 429. Do not silently truncate a write the caller thinks succeeded.


API5: Broken Function Level Authorization (BFLA)

Meaning. A normal user can call an admin or privileged function. BOLA is "this object". BFLA is "this action".

DELETE /api/users/123
Authorization: Bearer <member-token>

If the route only checks "logged in", any member can delete users.

func (s *Service) DeleteUser(ctx context.Context, actor User, targetID uuid.UUID) error {
    if !actor.HasRole(RoleAdmin) {
        return domain.ErrForbidden
    }
    return s.repo.DeleteUser(ctx, targetID)
}

Fix. Check role or permission on every privileged endpoint, server-side. Hiding a button in the UI is not a control. Admin routes live under a clear prefix (/api/admin/...) and still enforce the role in the service. Never trust a client-sent role field.


API6: Unrestricted Access to Sensitive Business Flows

Meaning. The endpoint is authorized, but the business process can be automated at scale: coupons, credits, bookings, enrichment jobs.

POST /api/coupons/apply
{"code":"WELCOME100"}

One legitimate apply is fine. A script applying it on every account, or starting 10,000 enrichment jobs, is abuse.

Fix. Add business controls on top of auth:

  • Per-user and per-code quotas
  • One-time or per-account coupon redemption
  • Rate limits tighter than generic API limits
  • Anomaly checks (velocity, duplicate payment fingerprints)
  • CAPTCHA or step-up auth only where a human should be in the loop

Auth answers "allowed". This item answers "allowed how often and at what cost".


API7: Server-Side Request Forgery (SSRF)

Meaning. The server fetches a URL the client chose, including internal hosts.

POST /api/fetch
{"url":"http://169.254.169.254/latest/meta-data/"}

Same class of bug: webhook testers, link unfurling, "import from URL", PDF fetchers. The server becomes a proxy into the VPC, metadata service, or localhost.

Fix.

Rule Detail
Allowlist destinations Scheme + host, not "block a few IPs"
Block link-local and private ranges 127.0.0.0/8, 10.0.0.0/8, 169.254.0.0/16, ::1
Resolve DNS then re-check the IP Stop DNS rebinding
No raw user URLs to http.Get Map an ID to a known upstream instead
Restrict egress Production network should not reach cloud metadata or internal admin ports

If you only need to store a URL, store it. Do not fetch it on the user's behalf unless the destination is known.


API8: Security Misconfiguration

Meaning. Defaults, debug, CORS, or error text leak enough to help an attacker.

{
  "error": "PostgreSQL connection failed",
  "host": "10.66.66.1",
  "username": "app"
}

Fix.

Setting Production value
Debug / stack traces Off. Log internally, return a generic error + request id
CORS Explicit origin allowlist. Never * with credentials
HTTP methods Only the verbs the route needs
Security headers nosniff, frame deny, HSTS on HTTPS
Secrets Env only. Never commit .env. Never echo them
Admin surfaces WireGuard / private network, not the public internet

Admin dashboards, SSH, and Postgres stay off the public interface. Apps on 80/443. That split is load-bearing.


API9: Improper Inventory Management

Meaning. Old or shadow APIs stay live and unpatched while you harden the current one.

/api/v1/users          still public, no object auth
/api/v2/users
/api/internal/debug    forgotten, no auth
/v1/leads              token API, different auth story

Fix. Keep an inventory of every public route, version, and auth mode. Document /api/... vs /v1/... and do not mix them. Deprecate with a date, then remove. Shadow routes (/internal, /debug, old webhooks) get auth or get deleted. OpenAPI or the router file is the source of truth; if it is not in the router, it should not be reachable.


API10: Unsafe Consumption of APIs

Meaning. Your service trusts a third-party response the same way it would trust your own database.

Your API -> provider -> {"email":"<script>alert(1)</script>","name":"..."}
                     -> you store it, render it, or pass it to the next call

Enrichment providers, Clerk webhooks, Stripe events, LLM output, and scraped pages are all untrusted input.

Fix. Validate and constrain before use:

  • Schema-check types, lengths, and enums
  • Store as data, not as HTML. Escape on render
  • Never eval, never concatenate provider text into SQL or shell
  • Webhooks: verify the signature (STRIPE_WEBHOOK_SECRET) before parsing
  • Time out and circuit-break outbound calls. A hung provider must not hang your handler

A signed webhook proves the sender. It does not prove the payload is safe to execute.


Checklist before you open a PR

  • Object access is scoped to the caller (user_id + id), not id alone
  • Request DTOs allowlist fields; domain models are not bound from JSON
  • Privileged actions check role in the service, not only in the UI
  • Lists are paginated; body size, fan-out, and spend have hard caps
  • Costly business flows have quotas or rate limits, not just login
  • No user-controlled URL is fetched unless the host is allowlisted
  • Prod errors are generic; CORS and debug are locked down
  • Old versions and /internal routes are documented or removed
  • Third-party and webhook payloads are verified, then treated as untrusted
  • user_id in the body is never used as the authenticated identity

When unsure, look at how enrichabl scopes jobs to the Clerk user and how webhook signatures are verified before any write.