OWASP API Security Top 10

The 10 most critical API security risks in 2026 — each with a vulnerable example and a Node.js fix

Last Updated:

Gartner predicts APIs will be the #1 enterprise attack vector in 2026. The OWASP API Security Top 10 (2023 edition) identifies the most critical risks — not exotic zero-days but fundamental design flaws that are entirely preventable. For each risk: what it is, a vulnerable example, and the fix.

This guide extends our API Security overview and Authentication guide with code-level detail on every OWASP API risk.

API1: Broken Object Level Authorization (BOLA)

Most common. Most damaging. An API endpoint is vulnerable when it accepts a resource ID but does not verify that the requesting user owns or has access to that resource.

// ❌ VULNERABLE — no ownership check
app.get('/api/invoices/:id', auth, async (req, res) => {
  const invoice = await Invoice.findById(req.params.id);
  res.json(invoice); // User can access ANY invoice by guessing the ID!
});

// ✅ FIXED — always scope to authenticated user
app.get('/api/invoices/:id', auth, async (req, res) => {
  const invoice = await Invoice.findOne({
    _id: req.params.id,
    userId: req.user.id   // ownership check
  });
  if (!invoice) return res.status(404).json({ error: 'Not found' });
  res.json(invoice);
});

API2: Broken Authentication

Weak authentication allows attackers to impersonate users or bypass access controls entirely.

// ❌ VULNERABLE — no signature verification, no expiry check
app.use((req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  req.user = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
  next(); // trusts the token without verifying the signature!
});

// ✅ FIXED — verify signature, issuer, audience, expiry
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');

app.use(async (req, res, next) => {
  const token = req.headers.authorization?.replace('Bearer ', '');
  if (!token) return res.status(401).json({ error: 'Missing token' });
  try {
    const decoded = jwt.verify(token, getPublicKey, {
      issuer: 'https://auth.example.com',
      audience: 'https://api.example.com',
      algorithms: ['RS256']
    });
    req.user = decoded;
    next();
  } catch (err) {
    res.status(401).json({ error: 'Invalid token' });
  }
});

For full OAuth2 implementation with PKCE and refresh token rotation, see our dedicated guide.

API3: Broken Object Property Level Authorization

The API exposes fields the user should not see, or allows users to update fields they should not be able to modify (mass assignment).

// ❌ VULNERABLE — mass assignment: user can change their own role
app.patch('/users/:id', auth, async (req, res) => {
  await User.findByIdAndUpdate(req.params.id, req.body); // req.body = { "role": "admin" }
});

// ✅ FIXED — allowlist updateable fields
app.patch('/users/:id', auth, async (req, res) => {
  const allowed = ['name', 'email', 'avatar'];
  const updates = Object.fromEntries(
    Object.entries(req.body).filter(([k]) => allowed.includes(k))
  );
  await User.findByIdAndUpdate(req.params.id, updates);
});

// ✅ FIXED — strip sensitive fields from responses
function sanitizeUser(user) {
  const { password, resetToken, internalNotes, ...safe } = user.toObject();
  return safe;
}

API4: Unrestricted Resource Consumption

No limits on request size, rate, or resource usage — enabling DoS attacks, data scraping, and runaway AI agent loops.

// ✅ FIX: multiple layers of limits
app.use(express.json({ limit: '100kb' }));  // body size limit

// Rate limiting (see /rate-limiting)
const ratelimit = require('express-rate-limit');
app.use('/api/', ratelimit({ windowMs: 60_000, max: 100 }));

// Pagination limits
app.get('/users', (req, res) => {
  const limit = Math.min(parseInt(req.query.limit) || 20, 100); // max 100
  const page  = Math.max(parseInt(req.query.page) || 1, 1);
  // ...
});

// Query complexity limit (GraphQL example)
app.use('/graphql', depthLimit(7), createComplexityLimit(1000));

API5: Broken Function Level Authorization

Regular users can access admin-only endpoints because authorization is not checked at the function level.

// ❌ VULNERABLE — admin endpoint is "hidden" but not protected
app.delete('/admin/users/:id', auth, deleteUser); // any authenticated user can access!

// ✅ FIXED — role check middleware
function requireRole(...roles) {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }
    next();
  };
}

app.delete('/admin/users/:id', auth, requireRole('admin'), deleteUser);
app.get('/admin/audit-log',    auth, requireRole('admin', 'auditor'), getAuditLog);

API6: Unrestricted Access to Sensitive Business Flows

Attackers automate business-logic abuse: bulk gift card purchases, seat hoarding, resale bot purchases. The endpoint works correctly but is being abused at scale.

// ✅ FIX: business-logic rate limiting
// Limit per user: 1 checkout attempt per 10 seconds
const checkoutLimit = ratelimit({
  windowMs: 10_000, max: 1,
  keyGenerator: req => req.user.id,
  message: { error: 'checkout_throttled', retry_after_seconds: 10 }
});
app.post('/checkout', auth, checkoutLimit, processCheckout);

// CAPTCHA for high-value flows
// Device fingerprinting for anomaly detection
// Max quantity per order per day per user

API7: Server Side Request Forgery (SSRF)

The API fetches a URL provided by the user — allowing attackers to scan internal networks or reach cloud metadata endpoints (AWS: 169.254.169.254).

// ❌ VULNERABLE — fetches any URL the user provides
app.post('/preview', auth, async (req, res) => {
  const { url } = req.body;
  const html = await fetch(url).then(r => r.text()); // SSRF!
  res.json({ preview: html.substring(0, 500) });
});

// ✅ FIXED — validate URL against allowlist and block private IPs
const { isIP } = require('net');
const dns = require('dns').promises;

async function isSafeUrl(urlStr) {
  try {
    const url = new URL(urlStr);

    // Only allow http/https
    if (!['http:', 'https:'].includes(url.protocol)) return false;

    // Resolve hostname and check for private IPs
    const { address } = await dns.lookup(url.hostname);
    const privateRanges = [/^127./, /^10./, /^192.168./, /^172.(1[6-9]|2d|3[01])./];
    if (privateRanges.some(r => r.test(address))) return false;
    if (address === '169.254.169.254') return false; // AWS metadata

    return true;
  } catch { return false; }
}

app.post('/preview', auth, async (req, res) => {
  if (!await isSafeUrl(req.body.url)) {
    return res.status(400).json({ error: 'URL not allowed' });
  }
  // proceed safely
});

API8: Security Misconfiguration

// ✅ Security headers middleware (add to every API)
app.use((req, res, next) => {
  res.set({
    'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload',
    'X-Content-Type-Options': 'nosniff',
    'X-Frame-Options': 'DENY',
    'Referrer-Policy': 'no-referrer',
    'Permissions-Policy': 'geolocation=()',
    'Content-Security-Policy': "default-src 'none'"
  });
  next();
});

// ✅ Disable Express fingerprinting
app.disable('x-powered-by');

// ✅ Never expose stack traces in production
app.use((err, req, res, next) => {
  res.status(err.status || 500).json({
    error: err.code || 'internal_error',
    message: process.env.NODE_ENV === 'production' ? 'Something went wrong' : err.message
    // Never: stack: err.stack in production!
  });
});

API9: Improper Inventory Management

Forgotten API versions, staging endpoints exposed to the internet, or undocumented internal APIs become attack surfaces.

  • Maintain an API inventory — all versions, all environments, all consumers
  • Remove (or password-protect) staging endpoints not needed for testing
  • Deprecate and retire old API versions on a documented schedule (see API Deprecation guide)
  • Audit third-party API dependencies — they can introduce vulnerabilities too

API10: Unsafe Consumption of APIs

Your API calls a third-party API and blindly trusts its response — allowing an attacker who compromises the upstream API to inject malicious data into your system.

// ❌ VULNERABLE — trusts third-party response shape completely
const thirdParty = await fetch('https://partner-api.com/users/' + userId).then(r => r.json());
await db.insertUser(thirdParty); // could inject unexpected fields, SQL via JSON, etc.

// ✅ FIXED — validate third-party responses against your schema
const Ajv = require('ajv');
const ajv = new Ajv();
const validatePartnerUser = ajv.compile({
  type: 'object',
  required: ['id', 'email'],
  additionalProperties: false,    // strip unexpected fields
  properties: {
    id:    { type: 'string', maxLength: 64 },
    email: { type: 'string', format: 'email' }
  }
});

const raw = await fetch('https://partner-api.com/users/' + userId).then(r => r.json());
if (!validatePartnerUser(raw)) throw new Error('Invalid third-party response');
await db.insertUser(raw); // safe — only allowlisted fields

Security Headers Checklist

HeaderValuePurpose
Strict-Transport-Securitymax-age=31536000; includeSubDomainsForce HTTPS
X-Content-Type-OptionsnosniffPrevent MIME sniffing
X-Frame-OptionsDENYPrevent clickjacking
Content-Security-Policydefault-src 'none'For API responses (no HTML)
Referrer-Policyno-referrerDon't leak URL in Referer