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 storyFix. 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 callEnrichment 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
/internalroutes are documented or removed - Third-party and webhook payloads are verified, then treated as untrusted
-
user_idin 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.