Let's skip the intro paragraph about "APIs are everywhere now" you already know that, you're the one building them. This is a practical rundown of what actually breaks APIs in production, with real code patterns, based on stuff I've fixed (and broken) myself.
The #1 bug: BOLA (Broken Object Level Authorization)
This is the one. If you remember nothing else from this post, remember this.
// ❌ Vulnerable
app.get('/api/orders/:id', authenticate, async (req, res) => {
const order = await Order.findById(req.params.id);
res.json(order);
});
This endpoint checks that someone is logged in. It never checks that the logged-in user actually owns the order they're requesting. Any authenticated user can enumerate /api/orders/1, /api/orders/2, /api/orders/3 and pull every order in your database.
// ✅ Fixed
app.get('/api/orders/:id', authenticate, async (req, res) => {
const order = await Order.findOne({ _id: req.params.id, userId: req.user.id });
if (!order) return res.status(404).json({ error: 'Not found' });
res.json(order);
});
Note the .findOne with userId baked into the query itself, not a separate if check after fetching. Fewer places to forget the check, fewer race conditions.
If you want the reference material this maps to, the OWASP API Security Top 10 covers this as its #1 category for a reason — it's the single most common finding in real-world API pentests.
#2: Function-level authorization gaps
Different flavor of the same bug. Instead of an object ID, it's a route that should require elevated permissions but doesn't check.
// ❌ Vulnerable any authenticated user can hit this
app.delete('/api/users/:id', authenticate, async (req, res) => {
await User.findByIdAndDelete(req.params.id);
res.sendStatus(204);
});
// ✅ Fixed
app.delete('/api/users/:id', authenticate, requireRole('admin'), async (req, res) => {
await User.findByIdAndDelete(req.params.id);
res.sendStatus(204);
});
Middleware order matters here. authenticate alone tells you who. requireRole tells you what they're allowed to do. Skipping the second one is how regular users end up with admin capabilities they discover by accident (or by fuzzing your route list).
#3: Excessive data exposure
Classic mistake: serializing your entire DB model straight into the response.
// ❌ Vulnerable
res.json(user); // includes passwordHash, internal flags, etc.
// ✅ Fixed — explicit allowlist, not a blocklist
res.json({
id: user.id,
name: user.name,
email: user.email
});
Blocklisting fields ("just don't send the password") fails the moment someone adds a new sensitive field to the schema and forgets to update the exclusion list. Allowlisting fails safe by default.
#4: No rate limiting on expensive endpoints
import rateLimit from 'express-rate-limit';
const strictLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
message: 'Too many requests, slow down.'
});
app.post('/api/password-reset', strictLimiter, handleReset);
app.post('/api/reports/generate', strictLimiter, handleReportGen);
Rate limiting isn't just anti-DDoS — it's anti-abuse for anything that's expensive per-request: password resets (email bombing), report generation (CPU/DB hammering), search endpoints (data scraping).
#5: Trusting third-party API responses blindly
If your service consumes another API, validate that response like it's user input — because functionally, it is.
// ✅ Validate third-party responses with the same rigor as user input
const schema = z.object({
status: z.enum(['success', 'failed']),
amount: z.number().positive(),
currency: z.string().length(3)
});
const parsed = schema.safeParse(thirdPartyResponse);
if (!parsed.success) {
throw new Error('Unexpected response shape from payment provider');
}
I've seen a production incident caused entirely by a partner API silently changing a field type. Nobody's fault except a missing validation layer.
A quick self-audit checklist
- [ ] Every object-fetching endpoint scopes the query to the authenticated user, not just checking "is logged in"
- [ ] Every admin/privileged route checks role, not just auth status
- [ ] Responses use explicit allowlists for fields, never raw model serialization
- [ ] Rate limits exist on password reset, search, export, and report-generation endpoints
- [ ] Third-party API responses are schema-validated before use
- [ ] Old/staging/internal endpoints are inventoried and either secured or killed
- [ ] Authorization tests re-run after any change to auth or permission logic — not just once at launch
If you want the fuller writeup mapping these against the current OWASP API Security Top 10 categories, I linked it above. Otherwise go grep your routes for findById without a scoped userId. I'd bet money you find at least one.
What's the sneakiest API auth bug you've found in your own codebase? Curious what patterns other people are running into.
Top comments (0)