Cybersecurity · API Security
OWASP API Security Top 10: A Developer's Field Guide for 2026
The OWASP API Security Top 10 lists the most critical API vulnerabilities. Most are fixable with straightforward code changes. This guide walks through each one with real examples.
Anurag Verma
8 min read
Sponsored
Most API security incidents don’t happen because an attacker found some novel zero-day exploit. They happen because a common, well-documented vulnerability was left in the codebase. The OWASP API Security Top 10 is a catalog of these: the mistakes that show up repeatedly across thousands of real-world breaches.
The 2023 edition of the list reflects what attackers are actually exploiting. This is a working guide, not a checklist. Each item includes what the attack looks like and what the fix is.
API1:2023 — Broken Object Level Authorization
The most common API vulnerability. An authenticated user can access objects that belong to other users by manipulating the identifier in the request.
What it looks like:
GET /api/orders/48291
Authorization: Bearer <your-token>
You’re authenticated as user 1234, but order 48291 belongs to user 5678. If the API returns it without checking ownership, that’s BOLA.
The fix:
Every endpoint that returns a specific object must verify the requesting user owns or is authorized to access that object. Never trust the ID in the URL alone.
// Vulnerable
app.get('/api/orders/:orderId', async (req, res) => {
const order = await db.orders.findById(req.params.orderId)
return res.json(order)
})
// Fixed
app.get('/api/orders/:orderId', async (req, res) => {
const order = await db.orders.findOne({
where: {
id: req.params.orderId,
userId: req.user.id, // Must match the authenticated user
},
})
if (!order) {
return res.status(404).json({ error: 'Not found' })
}
return res.json(order)
})
API2:2023 — Broken Authentication
Weak or missing authentication lets attackers impersonate legitimate users. This includes rate-limiting failures on login endpoints, weak JWT implementations, and credential exposure.
Common failures:
// Weak: no expiry on tokens
const token = jwt.sign({ userId: user.id }, SECRET)
// Weak: secret in version control or weak secret
const SECRET = 'password123'
// Weak: no rate limiting on login
app.post('/login', async (req, res) => {
// Allows unlimited brute-force attempts
})
The fix:
import rateLimit from 'express-rate-limit'
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
message: 'Too many login attempts',
standardHeaders: true,
legacyHeaders: false,
})
app.post('/login', loginLimiter, async (req, res) => {
// ...authentication logic
})
// JWTs with short expiry + refresh token rotation
const accessToken = jwt.sign(
{ userId: user.id },
process.env.JWT_SECRET!, // From environment, not code
{ expiresIn: '15m' }
)
API3:2023 — Broken Object Property Level Authorization
The updated version of what was previously called “Excessive Data Exposure.” Two sub-problems:
- API returns more fields than needed, and sensitive ones leak.
- API accepts more fields than intended (mass assignment).
Exposure:
// Leaks passwordHash, internalNotes, isAdmin to every caller
app.get('/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id)
return res.json(user) // Returns the full DB row
})
Mass assignment:
// An attacker can send { isAdmin: true } and get escalated
app.put('/users/:id', async (req, res) => {
await db.users.update(req.params.id, req.body)
})
The fix:
// Explicit field selection for read
const user = await db.users.findById(req.params.id, {
select: ['id', 'name', 'email', 'createdAt'],
})
// Explicit allowed fields for write
const allowedFields = ['name', 'email', 'bio']
const sanitized = pick(req.body, allowedFields)
await db.users.update(req.params.id, sanitized)
A schema validation library like Zod makes this structural rather than manual:
import { z } from 'zod'
const UpdateUserSchema = z.object({
name: z.string().max(100).optional(),
email: z.string().email().optional(),
bio: z.string().max(500).optional(),
})
app.put('/users/:id', async (req, res) => {
const body = UpdateUserSchema.parse(req.body) // Strips unknown fields
await db.users.update(req.params.id, body)
})
API4:2023 — Unrestricted Resource Consumption
APIs without rate limiting or resource bounds let attackers run expensive operations at scale: bulk downloads, search queries, SMS sends, AI inference calls.
The fix is layered:
import rateLimit from 'express-rate-limit'
// Per-IP rate limit
const apiLimiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
})
// Per-user rate limit (needs Redis store for distributed apps)
import RedisStore from 'rate-limit-redis'
const userLimiter = rateLimit({
windowMs: 60 * 1000,
max: 30,
keyGenerator: (req) => req.user?.id || req.ip,
store: new RedisStore({ client: redisClient }),
})
// Hard limits on expensive query parameters
app.get('/search', async (req, res) => {
const limit = Math.min(Number(req.query.limit) || 20, 100)
const page = Math.max(Number(req.query.page) || 1, 1)
// ...
})
Also: add pagination to every list endpoint. An endpoint that returns unbounded results without pagination is a data exfiltration waiting to happen.
API5:2023 — Broken Function Level Authorization
Different from BOLA (which is about objects). This is about actions: endpoints that perform privileged operations without checking whether the caller can perform them.
DELETE /api/admin/users/9123
POST /api/internal/sync-database
GET /api/admin/export-all-data
If these endpoints check authentication but not role, any logged-in user can hit them.
The fix:
// Role middleware
function requireRole(role: string) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user?.roles.includes(role)) {
return res.status(403).json({ error: 'Forbidden' })
}
next()
}
}
// Applied consistently
app.delete('/api/admin/users/:id', requireRole('admin'), deleteUser)
app.post('/api/admin/export', requireRole('admin'), exportUsers)
Don’t rely on hidden URLs. Any endpoint that performs a privileged action needs an explicit authorization check.
API6:2023 — Unrestricted Access to Sensitive Business Flows
Beyond individual endpoints, some API flows can be abused at the business logic level: automated bulk purchases of limited inventory, coupon stacking, promo abuse, mass account creation.
These don’t look like attacks at the HTTP level. They look like legitimate requests made rapidly.
Mitigations vary by business context:
- Device fingerprinting for signup flows
- CAPTCHA on high-value actions
- Velocity checks on purchases per account
- Phone verification before allowing transactions above a threshold
- Idempotency keys to prevent duplicate submissions
There’s no universal code fix. You need to model what abuse looks like for your specific flows.
API7:2023 — Server Side Request Forgery (SSRF)
When an API accepts a URL and fetches it server-side, an attacker can point that URL at internal services.
POST /api/fetch-preview
{"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"}
That IP is the AWS metadata endpoint. From inside a cloud instance, it returns IAM credentials.
The fix:
import dns from 'dns/promises'
import ipRangeCheck from 'ip-range-check'
const BLOCKED_RANGES = [
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
'127.0.0.0/8',
'169.254.0.0/16', // Link-local / metadata endpoints
'::1/128',
]
async function isSafeUrl(url: string): Promise<boolean> {
try {
const parsed = new URL(url)
if (!['http:', 'https:'].includes(parsed.protocol)) return false
const addresses = await dns.resolve(parsed.hostname)
return addresses.every(addr => !ipRangeCheck(addr, BLOCKED_RANGES))
} catch {
return false
}
}
app.post('/fetch-preview', async (req, res) => {
if (!(await isSafeUrl(req.body.url))) {
return res.status(400).json({ error: 'Invalid URL' })
}
// ...
})
API8:2023 — Security Misconfiguration
The broadest category. It covers everything from verbose error messages to open CORS policies to unpatched dependencies to missing security headers.
High-priority items:
import helmet from 'helmet'
import cors from 'cors'
// Helmet sets sensible security headers
app.use(helmet())
// CORS: allowlist specific origins, not *
app.use(cors({
origin: (origin, callback) => {
const allowed = ['https://app.yoursite.com', 'https://yoursite.com']
if (!origin || allowed.includes(origin)) {
callback(null, true)
} else {
callback(new Error('Not allowed by CORS'))
}
},
credentials: true,
}))
// Never leak stack traces to clients
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error(err) // Log server-side
res.status(500).json({ error: 'Internal server error' }) // Generic to client
})
Also: remove default routes, disable unnecessary HTTP methods, set appropriate cache headers on sensitive endpoints.
API9:2023 — Improper Inventory Management
You can’t secure what you don’t know about. Shadow APIs (old versions, internal endpoints, developer test routes) are frequently the actual entry point in breaches.
Practical steps:
- Maintain an API inventory. Every route in production should be documented.
- Version your APIs explicitly (
/v1/,/v2/) and decommission old versions on a published schedule. - Use API gateway logs to find routes that receive traffic but aren’t in your documented inventory.
- Run a weekly
grep -r 'app\.\(get\|post\|put\|delete\|patch\)' src/and compare against your API docs.
API10:2023 — Unsafe Consumption of APIs
When your API calls other APIs, you inherit their vulnerabilities if you don’t handle their responses carefully.
Common failures:
- Following redirects from external APIs without validating the target
- Trusting all data from a third-party API without validation
- Embedding third-party API responses directly into your database or responses
// Risky: trusts everything the payment provider returns
const { userId } = await paymentProvider.verifyWebhook(req.body)
await db.subscriptions.update({ userId, status: 'active' })
// Better: validate before acting on external data
const webhookData = WebhookSchema.parse(await paymentProvider.verifyWebhook(req.body))
const userId = webhookData.metadata.userId
// Verify the user exists before acting
const user = await db.users.findById(userId)
if (!user) throw new Error(`Unknown user ${userId} in webhook`)
await db.subscriptions.update({ userId: user.id, status: 'active' })
Also: validate webhook signatures. Every reputable payment and webhook provider (Stripe, GitHub, Twilio) gives you a signature to verify. Use it.
Putting It Together
None of these vulnerabilities require exotic knowledge to fix. Most reduce to three rules:
- Verify that the authenticated user can access the specific resource they’re requesting, not just that they’re authenticated.
- Validate and constrain every input (what you accept and what you return).
- Add rate limiting and monitoring to catch anomalies before they become incidents.
The OWASP list is a starting point, not a comprehensive security program. For APIs handling financial data, health records, or high volumes of personal information, a third-party penetration test against your API will find things an internal review won’t. The OWASP list tells you what to check for on your own; a good pentest tells you what you missed.
Sponsored
More from this category
More from Cybersecurity
Container Security Scanning in 2026: What Trivy and Snyk Find That Your Pipeline Misses
HTTP Security Headers in 2026: The Checklist That Actually Matters
Shadow AI in the Enterprise: The Security Gap Most Teams Haven't Closed
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored