Published on

OWASP API Security Top 10 — With Real Fixes for Each Vulnerability

Authors

Introduction

The OWASP API Security Top 10 represents the most critical risks for modern API-driven systems. Unlike traditional web application security, API security demands a different mindset: stateless authentication, fine-grained authorization at the resource level, and protection against abuse from both authenticated and unauthenticated users.

This post walks through each category with real, exploitable vulnerabilities and production-ready fixes that go beyond theoretical patching.

1. Broken Object Level Authorization (BOLA / IDOR)

BOLA is the #1 API vulnerability. Attackers enumerate resource IDs and access objects they shouldn't.

Vulnerable code:

// GET /api/invoices/123
// Attacker tries /api/invoices/999 and gains access to another customer's invoice
app.get('/api/invoices/:invoiceId', async (req, res) => {
  const invoice = await db.invoices.findById(req.params.invoiceId);
  res.json(invoice); // No ownership check!
});

Fix with ownership middleware:

import express, { Request, Response, NextFunction } from 'express';

interface AuthUser {
  id: string;
  organizationId: string;
}

declare global {
  namespace Express {
    interface Request {
      user?: AuthUser;
    }
  }
}

interface Invoice {
  id: string;
  userId: string;
  amount: number;
  description: string;
}

// Ownership verification middleware
const requireOwnership = (resourceField: string = 'ownerId') => {
  return async (
    req: Request,
    res: Response,
    next: NextFunction
  ): Promise<void> => {
    if (!req.user) {
      res.status(401).json({ error: 'Unauthorized' });
      return;
    }

    const resourceId = req.params.id;
    const resource = await db.resources.findById(resourceId);

    if (!resource || resource[resourceField] !== req.user.id) {
      res.status(403).json({ error: 'Forbidden' });
      return;
    }

    req.resource = resource;
    next();
  };
};

// Secure endpoint
app.get(
  '/api/invoices/:id',
  authenticate,
  requireOwnership('userId'),
  async (req: Request, res: Response) => {
    res.json(req.resource);
  }
);

// Advanced: Multi-tenant BOLA prevention
const requireMultiTenantOwnership = async (
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> => {
  if (!req.user) {
    res.status(401).json({ error: 'Unauthorized' });
    return;
  }

  const resourceId = req.params.id;
  const resource = await db.resources.findById(resourceId);

  // Check both ownership AND organization membership
  if (
    !resource ||
    resource.userId !== req.user.id ||
    resource.organizationId !== req.user.organizationId
  ) {
    res.status(403).json({ error: 'Forbidden' });
    return;
  }

  req.resource = resource;
  next();
};

app.get(
  '/api/organizations/:orgId/invoices/:id',
  authenticate,
  requireMultiTenantOwnership,
  async (req: Request, res: Response) => {
    res.json(req.resource);
  }
);

2. Broken Authentication

Weak JWT validation, no brute force protection, and missing token rotation:

import express from 'express';
import jwt from 'jsonwebtoken';
import rateLimit from 'express-rate-limit';

interface LoginAttempt {
  userId: string;
  timestamp: number;
  success: boolean;
}

class BruteForceDetection {
  private attempts: Map<string, LoginAttempt[]> = new Map();
  private readonly MAX_ATTEMPTS = 5;
  private readonly LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes

  recordAttempt(identifier: string, success: boolean): boolean {
    if (!this.attempts.has(identifier)) {
      this.attempts.set(identifier, []);
    }

    const attempts = this.attempts.get(identifier)!;
    const now = Date.now();

    // Remove old attempts
    const recentAttempts = attempts.filter(
      (a) => now - a.timestamp < this.LOCKOUT_DURATION
    );

    if (!success && recentAttempts.length >= this.MAX_ATTEMPTS) {
      return false; // Account locked
    }

    recentAttempts.push({ userId: identifier, timestamp: now, success });
    this.attempts.set(identifier, recentAttempts);
    return true;
  }

  isLocked(identifier: string): boolean {
    const attempts = this.attempts.get(identifier) || [];
    const now = Date.now();
    const recentAttempts = attempts.filter(
      (a) => now - a.timestamp < this.LOCKOUT_DURATION && !a.success
    );
    return recentAttempts.length >= this.MAX_ATTEMPTS;
  }
}

const bruteForce = new BruteForceDetection();

// Rate limit login endpoint
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10,
  message: 'Too many login attempts, please try again later',
  standardHeaders: true,
  legacyHeaders: false,
});

app.post(
  '/api/auth/login',
  loginLimiter,
  async (req: express.Request, res: express.Response) => {
    const { email, password } = req.body;

    if (bruteForce.isLocked(email)) {
      res.status(429).json({ error: 'Account temporarily locked' });
      return;
    }

    const user = await db.users.findByEmail(email);
    const passwordValid =
      user && (await bcrypt.compare(password, user.passwordHash));

    bruteForce.recordAttempt(email, !!passwordValid);

    if (!passwordValid) {
      res.status(401).json({ error: 'Invalid credentials' });
      return;
    }

    // Strong token generation with short expiration
    const accessToken = jwt.sign(
      { sub: user.id, email: user.email },
      process.env.JWT_SECRET!,
      { algorithm: 'HS256', expiresIn: '15m', issuer: 'api.example.com' }
    );

    res.json({
      accessToken,
      refreshToken: generateRefreshToken(user.id),
      expiresIn: 900,
    });
  }
);

const generateRefreshToken = (userId: string): string => {
  return jwt.sign(
    { sub: userId, type: 'refresh' },
    process.env.REFRESH_SECRET!,
    { expiresIn: '7d', algorithm: 'HS256' }
  );
};

3. Excessive Data Exposure

Return only necessary fields, implement allowlisting:

// Dangerous: Returns all user fields including password hash
app.get('/api/users/:id', async (req: express.Request, res: express.Response) => {
  const user = await db.users.findById(req.params.id);
  res.json(user); // Leak!
});

// Secure: Allowlist fields
interface UserDTO {
  id: string;
  email: string;
  name: string;
  createdAt: string;
}

const toUserDTO = (user: any): UserDTO => {
  return {
    id: user.id,
    email: user.email,
    name: user.name,
    createdAt: user.createdAt,
  };
};

app.get(
  '/api/users/:id',
  authenticate,
  async (req: express.Request, res: express.Response) => {
    const user = await db.users.findById(req.params.id);
    if (!user) {
      res.status(404).json({ error: 'Not found' });
      return;
    }
    res.json(toUserDTO(user));
  }
);

// List endpoints: Pagination + field filtering
app.get(
  '/api/users',
  authenticate,
  async (req: express.Request, res: express.Response) => {
    const page = Math.max(1, parseInt(req.query.page as string) || 1);
    const limit = Math.min(100, parseInt(req.query.limit as string) || 20);
    const skip = (page - 1) * limit;

    const users = await db.users.find().skip(skip).limit(limit);
    res.json({
      data: users.map(toUserDTO),
      pagination: { page, limit, total: await db.users.count() },
    });
  }
);

4. Lack of Rate Limiting

Protect endpoints from enumeration and DoS:

import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import redis from 'redis';

const redisClient = redis.createClient();

// Global rate limit
const globalLimiter = rateLimit({
  windowMs: 60 * 1000, // 1 minute
  max: 100,
  store: new RedisStore({
    client: redisClient,
    prefix: 'rl:global:',
  }),
});

// Strict limit on sensitive endpoints
const strictLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  skipSuccessfulRequests: true, // Only count failures
  store: new RedisStore({
    client: redisClient,
    prefix: 'rl:strict:',
  }),
});

// User-specific rate limit
const perUserLimiter = (req: express.Request, res: express.Response, next: express.NextFunction) => {
  const userId = req.user?.id || req.ip;
  const limiter = rateLimit({
    windowMs: 60 * 1000,
    max: 30,
    keyGenerator: () => userId,
    store: new RedisStore({
      client: redisClient,
      prefix: `rl:user:${userId}:`,
    }),
  });
  limiter(req, res, next);
};

app.use(globalLimiter);
app.post('/api/auth/login', strictLimiter, loginHandler);
app.get('/api/data', perUserLimiter, dataHandler);

5. Broken Function Level Authorization

Admin endpoints exposed to regular users:

interface UserRole {
  id: string;
  permissions: string[];
}

const hasPermission = (requiredPermission: string) => {
  return async (
    req: express.Request,
    res: express.Response,
    next: express.NextFunction
  ): Promise<void> => {
    if (!req.user) {
      res.status(401).json({ error: 'Unauthorized' });
      return;
    }

    const userRole = await db.roles.findById(req.user.roleId);
    if (!userRole || !userRole.permissions.includes(requiredPermission)) {
      res.status(403).json({ error: 'Forbidden' });
      return;
    }

    next();
  };
};

// Admin-only endpoint
app.delete(
  '/api/users/:id',
  authenticate,
  hasPermission('delete_users'),
  async (req: express.Request, res: express.Response) => {
    await db.users.deleteById(req.params.id);
    res.json({ success: true });
  }
);

// Role-based access with hierarchy
const ROLE_HIERARCHY: Record<string, string[]> = {
  admin: ['read', 'write', 'delete'],
  editor: ['read', 'write'],
  viewer: ['read'],
};

const checkRolePermission = (requiredRole: string) => {
  return (req: express.Request, res: express.Response, next: express.NextFunction) => {
    if (!req.user) {
      res.status(401).json({ error: 'Unauthorized' });
      return;
    }

    const userRoles = req.user.roles || [];
    const hasRole = userRoles.some(
      (role) => ROLE_HIERARCHY[role]?.includes(requiredRole)
    );

    if (!hasRole) {
      res.status(403).json({ error: 'Forbidden' });
      return;
    }
    next();
  };
};

6. Mass Assignment

Prevent attackers from setting fields they shouldn't:

// Vulnerable: Directly assigns all request fields
app.put('/api/users/:id', authenticate, async (req: express.Request, res: express.Response) => {
  const user = await db.users.findById(req.params.id);
  Object.assign(user, req.body); // Attacker sets isAdmin: true!
  await user.save();
  res.json(user);
});

// Secure: Field allowlist
interface UserUpdateRequest {
  name?: string;
  email?: string;
  profilePicture?: string;
}

const ALLOWED_UPDATE_FIELDS: (keyof UserUpdateRequest)[] = [
  'name',
  'email',
  'profilePicture',
];

app.put(
  '/api/users/:id',
  authenticate,
  requireOwnership('id'),
  async (req: express.Request, res: express.Response) => {
    const user = await db.users.findById(req.params.id);

    // Filter to allowed fields only
    const updates: Partial<UserUpdateRequest> = {};
    for (const field of ALLOWED_UPDATE_FIELDS) {
      if (field in req.body) {
        updates[field] = req.body[field];
      }
    }

    Object.assign(user, updates);
    await user.save();
    res.json(toUserDTO(user));
  }
);

// Alternative: Structured update
app.put(
  '/api/users/:id',
  authenticate,
  async (req: express.Request, res: express.Response) => {
    const { name, email } = req.body as UserUpdateRequest;

    const user = await db.users.findById(req.params.id);
    if (name !== undefined) user.name = name;
    if (email !== undefined) user.email = email;

    await user.save();
    res.json(toUserDTO(user));
  }
);

Checklist

  • Implement ownership checks on all resource endpoints (BOLA)
  • Enforce strong authentication with JWT + refresh tokens
  • Use DTOs to control response fields (data exposure)
  • Rate limit all endpoints, especially auth endpoints
  • Protect admin endpoints with function-level authorization
  • Allowlist fields in updates to prevent mass assignment
  • Validate against OWASP Injection Top 10 in all inputs
  • Secure API keys with rotation and scope restriction
  • Audit API changes and monitor access patterns
  • Test each OWASP category with automated scanning

Conclusion

The OWASP API Security Top 10 are not complex to fix—they require discipline. Ownership checks, allowlisted fields, rate limits, and explicit authorization are the foundation. Layer these controls consistently across your API, and you eliminate the vast majority of attacks that target APIs in production.