Skip to content
ExponaBLOG
ENGINEERING CRAFT · DAY 072 / 100

OWASP API Security risks for developers, explained simply

The API vulnerabilities I check for in every review.

Expona Admin9 min read

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:

typescript
// 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:

typescript
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:

typescript
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:

typescript
// 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:

typescript
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:

typescript
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:

typescript
// 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:

typescript
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:

typescript
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:

typescript
// 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:

typescript
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:

typescript
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:

typescript
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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles