OWASP API Security Top 10: A Developer's Practical Defense Guide

Walk through every OWASP API Security Top 10 vulnerability with real attack examples and code-level mitigations you can ship this week.

Tech Talk News Editorial10 min read
#security#api#owasp#cybersecurity#backend
ShareXLinkedInRedditEmail
OWASP API Security Top 10: A Developer's Practical Defense Guide

Most API breaches aren't sophisticated. They're BOLA, bad auth, or someone forgetting to check who owns the resource they're modifying. The OWASP API Security Top 10 isn't a theoretical exercise. It's a ranked list of vulnerabilities that show up repeatedly in real production systems, year after year. What's frustrating is that in 2025, these bugs still ship. Not because developers are careless, but because the patterns that produce them are easy to miss at review time and invisible until someone exploits them.

My take going in: SSRF is the one that catches teams most off guard. Most people read the BOLA section and nod along. They get to SSRF and think it won't apply to them. It usually does. More on that later.

API1: Broken Object Level Authorization (BOLA)

BOLA, also known as Insecure Direct Object Reference, is the number one API vulnerability and has held that position across two OWASP cycles. The classic example is an e-commerce API where order IDs are sequential. You're logged in as user A, looking at your order with ID 10042. You change it to 10041 in the URL. If the API doesn't check ownership, you're reading someone else's order. Simple, devastating, embarrassingly common.

// VULNERABLE : no ownership check
app.get('/api/invoices/:id', async (req, res) => {
  const invoice = await db.invoices.findById(req.params.id)
  return res.json(invoice) // Any authenticated user can read any invoice
})

// FIXED : enforce ownership at query level
app.get('/api/invoices/:id', async (req, res) => {
  const invoice = await db.invoices.findOne({
    id: req.params.id,
    ownerId: req.user.id  // Filter by authenticated user
  })
  if (!invoice) return res.status(404).json({ error: 'Not found' })
  return res.json(invoice)
})

The fix isn't validation. It's scoping. Always include the authenticated user's identity as a query filter. For multi-tenant systems, include the tenant ID too. Integration tests that log in as user A and request user B's resource ID should be mandatory for every API.

API2: Broken Authentication

Broken authentication covers a wide surface: weak credential policies, JWTs without expiration, tokens not invalidated on logout, missing rate limiting on login endpoints, and insecure password reset flows. The most common production failure I've seen is accepting JWTs signed with the none algorithm, which is exactly as bad as it sounds.

// VULNERABLE : accepts 'none' algorithm
const decoded = jwt.verify(token, secret, {}) // algorithms not restricted

// FIXED : explicitly restrict accepted algorithms
const decoded = jwt.verify(token, secret, {
  algorithms: ['RS256'],  // or HS256 : never 'none'
  issuer: 'https://auth.yourapp.com',
  audience: 'api.yourapp.com',
})

Beyond the algorithm check: implement exponential backoff and account lockout on failed logins, rotate refresh tokens on every use, store token JTIs in Redis to enable revocation, and require re-authentication for sensitive actions regardless of session age.

API3: Broken Object Property Level Authorization

Where BOLA is about fetching objects you shouldn't see, API3 is about reading or writing properties within an object you shouldn't touch. Mass assignment is the classic attack vector: a client POSTs a JSON body and the server blindly maps it to a model. A user adds "role": "admin" to their profile update request and suddenly they're an admin. This happens.

// VULNERABLE : mass assignment, user can set their own role
app.put('/api/users/:id', async (req, res) => {
  const updated = await db.users.update(req.params.id, req.body)
  return res.json(updated)
})

// FIXED : allowlist the fields callers are permitted to set
app.put('/api/users/:id', async (req, res) => {
  const { displayName, email, avatarUrl } = req.body // Only these
  const updated = await db.users.update(req.params.id, {
    displayName, email, avatarUrl
  })
  return res.json(pick(updated, ['id', 'displayName', 'email'])) // Prune response too
})

Both sides matter: filter what comes in (allowlist writable fields) and prune what goes out. Never serialize internal fields like passwordHash, role, or stripeCustomerId unless explicitly required by the endpoint.

API4: Unrestricted Resource Consumption

Without limits on request rate, payload size, query complexity, or execution time, attackers can exhaust your infrastructure with minimal effort. This isn't just a DoS concern. In serverless and per-request-billed environments, it's a direct cost attack. Someone can run up your cloud bill without ever touching your data.

  • Set hard limits on request body size at the reverse proxy (nginx: client_max_body_size 10m)
  • Apply per-IP and per-user rate limits with sliding window counters in Redis
  • For GraphQL, use query depth limiting and complexity analysis (graphql-depth-limit, graphql-query-complexity)
  • Set database query timeouts; a slow query is often more dangerous than a fast one
  • Paginate every collection endpoint; never return unbounded result sets
// Express rate limiter with Redis store
import rateLimit from 'express-rate-limit'
import RedisStore from 'rate-limit-redis'

const apiLimiter = rateLimit({
  windowMs: 60 * 1000,   // 1 minute window
  max: 100,              // 100 requests per window per IP
  standardHeaders: true,
  legacyHeaders: false,
  store: new RedisStore({ client: redisClient }),
  keyGenerator: (req) => req.user?.id ?? req.ip, // Per-user when authenticated
})

API5: Broken Function Level Authorization

This is the administrative endpoint problem. Admin functionality lives at the same API layer as user functionality, protected only by a role check that's easy to miss, misconfigure, or bypass. Attackers enumerate HTTP verbs and path patterns like DELETE /api/users/:id, /api/admin/export, and PUT /api/orders/:id/status, looking for endpoints that perform privileged actions without verifying the caller's role.

The right answer: separate your admin API surface onto a different subdomain or port that is never reachable from the public internet. Apply middleware-level role checks that fail closed. Audit every route for missing authorization middleware using static analysis. For Express, a route audit script that checks for unprotected routes is a worthwhile CI investment. It takes a few hours to build and catches real bugs.

API6: Unrestricted Access to Sensitive Business Flows

Some API flows are technically authorized but shouldn't be automatable at scale: creating discount codes, submitting referral signups, voting, or purchasing limited inventory. Bots that can call these endpoints at machine speed cause real business harm: gift card fraud, inventory hoarding, vote manipulation. This is a common revenue leak that doesn't show up in security audits.

Defenses go beyond rate limiting: device fingerprinting, CAPTCHA at critical flows, velocity checks on business-level signals (multiple accounts from the same IP purchasing within seconds), and behavioral anomaly detection. Consider requiring a valid browser fingerprint or proof-of-work token for high-value flows rather than relying on rate limits alone.

API7: Server Side Request Forgery (SSRF)

This is the one that catches teams off guard. SSRF occurs when an API accepts a user-supplied URL and fetches it server-side, allowing attackers to probe internal services, cloud metadata endpoints, or exfiltrate credentials. In AWS, fetching http://169.254.169.254/latest/meta-data/iam/security-credentials/ from a vulnerable endpoint hands over temporary AWS credentials. That's full account access in one HTTP request.

The reason it catches teams off guard: the vulnerable code pattern looks completely reasonable. "Our users can enter a webhook URL and we'll send them events." Fine. But if you don't validate that URL, you've introduced SSRF.

// VULNERABLE : fetches arbitrary URLs from user input
app.post('/api/preview', async (req, res) => {
  const { url } = req.body
  const html = await fetch(url).then(r => r.text())
  res.json({ preview: html })
})

// FIXED : validate against allowlist before fetching
import { URL } from 'url'
const ALLOWED_DOMAINS = new Set(['partner-api.example.com', 'cdn.example.com'])

app.post('/api/preview', async (req, res) => {
  let parsed: URL
  try { parsed = new URL(req.body.url) } catch { return res.status(400).end() }

  if (!ALLOWED_DOMAINS.has(parsed.hostname)) {
    return res.status(400).json({ error: 'Domain not permitted' })
  }
  // Also block private IP ranges via DNS resolution check
  const html = await fetch(parsed.toString()).then(r => r.text())
  res.json({ preview: html })
})
Cloud metadata endpoints are the crown jewels for SSRF attackers. Block access to 169.254.169.254 and fd00:ec2::254 at the network layer regardless of your application-level defenses.

API8: Security Misconfiguration

Security misconfiguration is the broadest category: verbose error messages exposing stack traces, CORS wildcards in production, missing security headers, default credentials on backing services, unnecessary HTTP methods enabled, and outdated TLS versions. A checklist for getting this right:

  • Set Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, and Strict-Transport-Security headers
  • CORS: never use origin: '*' with credentials: true. This combination is actively exploitable.
  • Disable X-Powered-By and server version headers
  • Return generic error messages in production; log details server-side with a correlation ID
  • Run TLS 1.2+ only; enforce cipher suite allowlists
  • Audit your OPTIONS responses; don't advertise methods you don't intend to support

API9: Improper Inventory Management

Shadow APIs (old API versions, internal APIs accidentally exposed, or third-party APIs integrated without security review) are a major source of breaches. If you don't know what API endpoints exist, you can't secure them. This sounds obvious, but in practice most organizations have no idea how many endpoints they're actually running.

Maintain an API inventory using OpenAPI/Swagger specs committed to your repository and generated from your route definitions, not maintained by hand. Use tools like Spectral for linting, 42Crunch for security analysis, and Salt Security or Noname for runtime API discovery that catches undocumented endpoints in traffic. Establish a deprecation policy: old API versions must be decommissioned on a schedule, not left running indefinitely.

API10: Unsafe Consumption of APIs

Your security posture is only as strong as your weakest dependency. Third-party APIs you consume can return unexpected data shapes, inject content, redirect to malicious URLs, or become compromised themselves. Treat third-party API responses with the same skepticism you apply to user input. This is rarely done in practice.

// VULNERABLE : trusts and renders third-party response directly
const { html } = await fetch('https://partner-api.example.com/widget').then(r => r.json())
container.innerHTML = html  // XSS if partner API is compromised

// FIXED : validate schema and sanitize content
import { z } from 'zod'
import DOMPurify from 'dompurify'

const WidgetSchema = z.object({
  title: z.string().max(200),
  html: z.string().max(10000),
})

const raw = await fetch('https://partner-api.example.com/widget').then(r => r.json())
const widget = WidgetSchema.parse(raw)  // Throws if shape unexpected
container.innerHTML = DOMPurify.sanitize(widget.html)  // Sanitize before render

Start with BOLA and broken authentication. They're responsible for the majority of real-world API breaches and they're both fixable at the code level without major architectural changes. SSRF deserves early attention because it's underrated and the blast radius when it gets exploited is severe. The teams that do this well treat security review as part of API design, not a checkpoint at the end. That's the only posture that actually works.

ShareXLinkedInRedditEmail