OWASP API Security Top 10
The 10 most critical API security risks in 2026 — each with a vulnerable example and a Node.js fix
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
| Header | Value | Purpose |
|---|---|---|
| Strict-Transport-Security | max-age=31536000; includeSubDomains | Force HTTPS |
| X-Content-Type-Options | nosniff | Prevent MIME sniffing |
| X-Frame-Options | DENY | Prevent clickjacking |
| Content-Security-Policy | default-src 'none' | For API responses (no HTML) |
| Referrer-Policy | no-referrer | Don't leak URL in Referer |