After five years building production platforms, I've learned that most API breaches don't happen because of exotic zero-days—they happen because we skip the basics. OWASP API Security risks for developers are fundamentals that belong in every code review, yet I still see them slip through regularly. This post covers the four vulnerabilities I check for first, with practical examples from real systems I've worked on.
Broken object-level authorization — Users can access other users' records by changing an ID
This is the easiest vulnerability to introduce and one of the hardest to catch in testing. The problem: we validate that a user is authenticated, then assume they're authorized to access whatever they request. In practice, an attacker changes an ID parameter and gains access to someone else's data.
I saw this in a travel platform we inherited. The booking API looked like this:
// GET /api/bookings/123
// Returns full booking object for any authenticated user
app.get('/api/bookings/:bookingId', authMiddleware, async (req, res) => {
const booking = await db.bookings.findOne({ id: req.params.bookingId });
res.json(booking); // No check that req.user owns this booking
});An attacker could increment the ID and pull anyone's itinerary, payment method, and passport details. The fix requires checking ownership on every resource access:
app.get('/api/bookings/:bookingId', authMiddleware, async (req, res) => {
const booking = await db.bookings.findOne({
id: req.params.bookingId,
userId: req.user.id // Enforce ownership
});
if (!booking) {
return res.status(404).json({ error: 'Not found' });
}
res.json(booking);
});The key insight: return 404 when access is denied, not 403. Returning 403 confirms the resource exists, which leaks information. A 404 tells the attacker nothing.
This pattern must extend to nested resources. If you have /api/users/:userId/payments/:paymentId, verify ownership at both levels. I typically wrap this in middleware:
const authorizeResource = (resourceField) => async (req, res, next) => {
const resource = await db[resourceField].findOne({
id: req.params[`${resourceField}Id`],
userId: req.user.id
});
if (!resource) return res.status(404).json({ error: 'Not found' });
req.resource = resource;
next();
};
app.get('/api/payments/:paymentId', authMiddleware, authorizeResource('payments'), (req, res) => {
res.json(req.resource);
});Broken authentication — Weak token handling, missing rate limits on login
Broken authentication means your auth implementation has gaps that let attackers bypass or compromise credentials. The most common issues I find: tokens with predictable values, no expiration, weak refresh token rotation, and zero rate limiting on login endpoints.
I reviewed a ride-hailing platform where refresh tokens never expired. Once an attacker obtained a refresh token from a leaked database, they could generate new access tokens indefinitely:
// Bad: No expiration, no rotation
app.post('/auth/refresh', async (req, res) => {
const decoded = jwt.verify(req.body.refreshToken, SECRET);
const newAccessToken = jwt.sign({ userId: decoded.userId }, SECRET, { expiresIn: '15m' });
res.json({ accessToken: newAccessToken });
// Attacker reuses the same refresh token forever
});The fix involves token rotation and strict expiration:
app.post('/auth/refresh', async (req, res) => {
const decoded = jwt.verify(req.body.refreshToken, SECRET);
// Check that this refresh token is still in the database
const storedToken = await db.refreshTokens.findOne({
token: req.body.refreshToken,
userId: decoded.userId
});
if (!storedToken || storedToken.expiresAt < new Date()) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// Mark old token as revoked
await db.refreshTokens.update({ id: storedToken.id }, { revoked: true });
// Issue new tokens
const newAccessToken = jwt.sign({ userId: decoded.userId }, SECRET, { expiresIn: '15m' });
const newRefreshToken = crypto.randomBytes(32).toString('hex');
await db.refreshTokens.create({
token: newRefreshToken,
userId: decoded.userId,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days
});
res.json({ accessToken: newAccessToken, refreshToken: newRefreshToken });
});Rate limiting on login is equally critical. Without it, attackers run brute-force attacks at scale:
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts
keyGenerator: (req) => req.body.email, // Limit per email
message: 'Too many login attempts, try again later'
});
app.post('/auth/login', loginLimiter, async (req, res) => {
// Login logic
});Excessive data exposure — APIs returning full objects and relying on the UI to hide fields
This is a design smell I catch frequently. The server returns every field in the database, then the frontend hides sensitive data with CSS or conditional rendering. If an attacker calls the API directly, all that "hidden" data appears.
A CRM platform we worked on had this problem. The user endpoint returned salary history, performance reviews, and personal notes:
// Bad: Returns all fields regardless of context
app.get('/api/users/:userId', authMiddleware, authorizeResource('users'), async (req, res) => {
const user = await db.users.findOne({ id: req.params.userId });
res.json(user); // Includes password hash, internal notes, salary
});The attacker's perspective: call the API and extract everything. The fix is controlled field exposure:
app.get('/api/users/:userId', authMiddleware, authorizeResource('users'), async (req, res) => {
const user = await db.users.findOne({ id: req.params.userId });
// Return only safe fields
const safeUser = {
id: user.id,
name: user.name,
email: user.email,
avatar: user.avatar,
createdAt: user.createdAt
};
res.json(safeUser);
});For admin contexts, you need different endpoints or controlled access. Create separate response models for different caller types:
const userResponses = {
public: (user) => ({ id: user.id, name: user.name, avatar: user.avatar }),
own: (user) => ({ id: user.id, name: user.name, email: user.email, avatar: user.avatar }),
admin: (user) => user // Full object for admins
};
app.get('/api/users/:userId', authMiddleware, authorizeResource('users'), async (req, res) => {
const user = req.resource;
const responseType = req.user.id === user.id ? 'own' : (req.user.admin ? 'admin' : 'public');
res.json(userResponses[responseType](user));
});Unrestricted resource consumption — No limits on page sizes, uploads or request rates
Unrestricted resource consumption lets attackers crash your API or steal data at massive scale. Three scenarios I've handled: pagination without limits, file uploads without validation, and no global rate limiting.
A trading platform had pagination like this:
// Bad: User can request any page size
app.get('/api/transactions', authMiddleware, async (req, res) => {
const limit = req.query.limit || 10; // User controls this
const transactions = await db.transactions.find({ userId: req.user.id }).limit(limit);
res.json(transactions);
});An attacker sets limit=1000000 and pulls the entire database in one request, overloading memory and burning through query limits. The fix:
app.get('/api/transactions', authMiddleware, async (req, res) => {
const MAX_LIMIT = 100;
const DEFAULT_LIMIT = 20;
let limit = parseInt(req.query.limit, 10) || DEFAULT_LIMIT;
limit = Math.min(limit, MAX_LIMIT); // Cap it
const offset = Math.max(0, (parseInt(req.query.page, 10) || 1) - 1) * limit;
const transactions = await db.transactions
.find({ userId: req.user.id })
.limit(limit)
.offset(offset);
res.json(transactions);
});For file uploads, always validate size and type:
const multer = require('multer');
const uploadLimits = multer({
limits: {
fileSize: 5 * 1024 * 1024 // 5MB
},
fileFilter: (req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'application/pdf'];
if (allowed.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Invalid file type'));
}
}
});
app.post('/api/uploads', authMiddleware, uploadLimits.single('file'), async (req, res) => {
// Process upload
});Finally, add global rate limiting to protect the entire API:
const globalLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100, // 100 requests per minute per IP
keyGenerator: (req) => req.user?.id || req.ip // Per user if authenticated, else per IP
});
app.use(globalLimiter);Common mistakes
- Assuming frontend validation prevents API abuse: Frontend code is always visible and can be bypassed. Every validation rule must live on the server.
- Using the same token for all purposes: Access and refresh tokens should be separate, stored differently, and have different expiration times.
- Hiding sensitive data with UI logic instead of API filtering: An attacker with direct API access bypasses all frontend controls.
- Testing only happy paths: Security issues emerge when you pass unexpected values: negative page sizes, enormous IDs, null fields, empty arrays.
- Forgetting about nested resources: If you secure
/api/users/:userId, also secure/api/users/:userId/payments,/api/users/:userId/settings, and every other nested endpoint.
Key takeaways
- Check ownership on every resource read and write; return 404 for unauthorized access, never 403.
- Implement token rotation, expiration, and rate limiting on all auth endpoints.
- Never rely on the UI to hide sensitive data; filter fields at the API level based on caller context.
- Enforce hard limits on pagination, file uploads, and request rates; make these configurable but safe by default.
- Review these four OWASP API Security risks for developers in every code review—they're the foundation.






Comments
Loading comments…