API Security Best Practices: How to Secure Your APIs Against Real-World Attacks

API Security Best Practices: How to Secure Your APIs Against Real-World Attacks
30 views

APIs are the number one attack surface for modern web applications. OWASP’s API Security Top 10 describes vulnerabilities that attackers exploit daily — broken authentication, excessive data exposure, mass assignment, and injection attacks. This guide covers each threat with the actual code-level defenses that prevent them, not just theory.

⚡ TL;DR: Validate every input server-side. Use short-lived JWTs with refresh tokens. Implement rate limiting on every endpoint. Return minimum data (never expose internal fields). Use parameterized queries — never string concatenation. Set CORS to specific origins. Log auth failures. Never trust client-provided IDs for authorization.

OWASP API Top 10 — the real threats

// 1. Broken Object Level Authorization (BOLA/IDOR) — most common API flaw
// Attacker changes ID in URL to access other users' data

// VULNERABLE:
app.get('/invoices/:id', auth, async (req, res) => {
 const invoice = await db.getInvoice(req.params.id);
 res.json(invoice); // Returns invoice regardless of who owns it!
});
// Attacker: GET /invoices/12345 (not their invoice) → gets it!

// SECURE:
app.get('/invoices/:id', auth, async (req, res) => {
 const invoice = await db.query(
 'SELECT * FROM invoices WHERE id = $1 AND user_id = $2',
 [req.params.id, req.user.id] // ALWAYS scope to authenticated user
 );
 if (!invoice) return res.status(404).json({ error: 'Not found' });
 res.json(invoice);
});

// 2. Broken Authentication
// VULNERABLE: no token expiry, weak secret, algorithm=none
const token = jwt.sign(payload, 'weak');
// SECURE:
const token = jwt.sign(payload, process.env.JWT_SECRET, {
 expiresIn: '15m', // Short-lived
 algorithm: 'HS256' // Always specify algorithm explicitly
});
// ALWAYS verify: jwt.verify(token, secret, { algorithms: ['HS256'] })

Input validation and injection prevention

import { z } from 'zod';

// Validate EVERYTHING from the client — never trust request data
const CreateUserSchema = z.object({
 name: z.string().min(1).max(100).trim(),
 email: z.string().email().toLowerCase(),
 age: z.number().int().min(18).max(120),
 role: z.enum(['user', 'moderator']).default('user'),
 // NEVER accept: 'admin', isAdmin: true, userId, internalId, etc.
});

app.post('/users', async (req, res) => {
 const parsed = CreateUserSchema.safeParse(req.body);
 if (!parsed.success) return res.status(422).json({ error: parsed.error.format() });

 const { name, email, age, role } = parsed.data; // Only allowed fields
 await db.createUser({ name, email, age, role }); // Mass assignment prevented
});

// SQL injection prevention — ALWAYS parameterized queries
// VULNERABLE:
const user = await db.query(`SELECT * FROM users WHERE email = '${email}'`);
// Attacker: email = "x' OR 1=1 --" → returns all users!

// SECURE:
const user = await db.query('SELECT * FROM users WHERE email = $1', [email]);
// $1 is a parameter — never concatenated into SQL

Rate limiting and DDoS protection

import rateLimit from 'express-rate-limit';

// Different limits for different endpoints
const authLimiter = rateLimit({
 windowMs: 15 * 60 * 1000, // 15 minutes
 max: 10, // 10 attempts per IP per 15 min
 standardHeaders: true, // Return rate limit headers
 message: { error: 'Too many attempts, try again in 15 minutes' },
});

const apiLimiter = rateLimit({
 windowMs: 60 * 1000, // 1 minute
 max: 100, // 100 requests per minute
});

// Apply different limits to different routes
app.use('/auth/', authLimiter); // Stricter: prevent brute force
app.use('/api/', apiLimiter); // Standard: prevent abuse

// Also limit by API key (not just IP):
const keyLimiter = rateLimit({
 keyGenerator: (req) => req.headers['x-api-key'] || req.ip,
 max: 1000, // Per API key per minute
});

CORS and security headers

import cors from 'cors';
import helmet from 'helmet';

// WRONG: allow all origins
app.use(cors()); // Access-Control-Allow-Origin: *
// Anyone can call your API with a browser request from any domain!

// CORRECT: specific allowed origins
const allowedOrigins = [
 'https://myapp.com',
 'https://admin.myapp.com',
];

app.use(cors({
 origin: (origin, callback) => {
 if (!origin || allowedOrigins.includes(origin)) callback(null, true);
 else callback(new Error('CORS: Origin not allowed'));
 },
 credentials: true,
 methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
}));

// Helmet: sets secure HTTP headers automatically
app.use(helmet()); // Sets: X-Content-Type-Options, X-Frame-Options,
 // Content-Security-Policy, Strict-Transport-Security, etc.

Node.js-specific attacks: prototype pollution, ReDoS and SSRF

Three attacks deserve extra attention in Node.js APIs, because they exploit how JavaScript and the event loop work.

Prototype pollution

If user-controlled JSON reaches a recursive merge or a set(obj, path, value) helper, a key like __proto__ can write to Object.prototype and change the behavior of every object in the process. Even a shallow Object.assign({}, body) with a parsed __proto__ key replaces the target’s prototype, so settings.isAdmin suddenly reads true. Validate the request body against a schema that allows only known keys, merge into null-prototype objects, skip the dangerous keys, and keep merge libraries patched.

ReDoS

Regular expressions with nested quantifiers, such as /^(a+)+$/, can backtrack exponentially on crafted input. Because JavaScript runs on one thread, one slow match blocks every request. Never compile user-supplied patterns, review regexes that run on user input (tools such as safe-regex flag risky patterns), cap input length, or use a linear-time engine such as RE2 through the re2 package.

SSRF

When your server fetches a URL that a user supplied (webhooks, link previews, imports), an attacker can point it at internal services or the cloud metadata endpoint. Checking the hostname string isn’t enough: localhost, decimal IPs and DNS names that resolve to private addresses all get past a naive filter. Resolve the name and check every resolved address against a block list, as below, then connect to the address you checked and re-check after each redirect, or disable redirects.

javascript
import { lookup } from "node:dns/promises";
import { BlockList, isIP } from "node:net";

// 1. Prototype pollution: copy only own, safe keys into a null-prototype object.
const FORBIDDEN = new Set(["__proto__", "constructor", "prototype"]);

function safeMerge(target, source) {
  for (const [key, value] of Object.entries(source)) {
    if (FORBIDDEN.has(key)) continue;
    if (value && typeof value === "object" && !Array.isArray(value)) {
      target[key] = safeMerge(target[key] ?? Object.create(null), value);
    } else {
      target[key] = value;
    }
  }
  return target;
}

const body = JSON.parse('{"theme":"dark","__proto__":{"isAdmin":true}}');
const settings = safeMerge(Object.create(null), body);
console.log(settings.theme, settings.isAdmin, ({}).isAdmin);   // dark undefined undefined

// 2. SSRF: check every resolved address, not the hostname string.
const blocked = new BlockList();
for (const [net, prefix] of [["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
                             ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.168.0.0", 16]]) {
  blocked.addSubnet(net, prefix, "ipv4");
}
for (const [net, prefix] of [["::1", 128], ["fc00::", 7], ["fe80::", 10]]) {
  blocked.addSubnet(net, prefix, "ipv6");
}

async function assertPublicUrl(raw) {
  const url = new URL(raw);
  if (!["http:", "https:"].includes(url.protocol)) throw new Error("only http(s) allowed");
  const host = url.hostname.replace(/^\[|\]$/g, "");
  const addrs = isIP(host) ? [{ address: host, family: isIP(host) }]
                           : await lookup(host, { all: true });
  for (const { address, family } of addrs) {
    const mapped = address.toLowerCase().startsWith("::ffff:") ? address.slice(7) : address;
    const type = isIP(mapped) === 4 ? "ipv4" : "ipv6";
    if (blocked.check(mapped, type)) throw new Error(`blocked address ${address}`);
  }
  return { url, addrs };   // connect to one of these exact addresses, and re-check after redirects
}

for (const u of ["http://127.0.0.1/admin", "http://169.254.169.254/latest/meta-data/", "http://localhost:8080/", "file:///etc/passwd"]) {
  try { await assertPublicUrl(u); console.log("allowed", u); }
  catch (e) { console.log("rejected", u, "-", e.message); }
}

Run with Node.js 20, it prints dark undefined undefined for the merge, then rejects all four URLs, including localhost, which resolved to ::1. On AWS, also require IMDSv2 on instances so a forwarded request can’t read credentials from the metadata service. See the OWASP SSRF prevention cheat sheet and the prototype pollution cheat sheet.

API security checklist

  • ✅ BOLA: always scope DB queries to authenticated user’s ID
  • ✅ Validate all inputs with schema validation (Zod, Joi, Yup)
  • ✅ Parameterized queries — never string-concatenated SQL
  • ✅ Short-lived JWTs (15m access + 7d refresh) with explicit algorithm
  • ✅ Rate limiting on auth endpoints (10 req/15min) and all API endpoints
  • ✅ CORS: specific origins, not wildcard
  • ✅ Helmet.js for security headers
  • ✅ Log auth failures, flag suspicious patterns
  • ❌ Never trust client-provided user IDs, roles, or permissions
  • ❌ Never return stack traces, SQL queries, or internal paths in errors

API security connects to the REST API design guide — security decisions should be made at design time, not retrofitted. The AWS S3 presigned URL guide shows how authentication works at the infrastructure layer. External reference: OWASP API Security Top 10.

Free Weekly Newsletter

🚀 Don’t Miss the Next Cheat Code

✓ No spam✓ Unsubscribe anytime✓ Expert-level only

Related: Node.js Security Best Practices: Protect Your API From the Most Common Attacks · Express.js Best Practices: Build Production-Ready APIs With Node.js · Day 68: Abuse and Cost Attacks

Related: HTTP Status Codes Explained: Every Code With Real-World Examples and When to Use Each · GraphQL vs REST: A Practical Comparison for Production APIs in 2025 · Docker Best Practices: Build Smaller, Faster, More Secure Images in Production

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

Already subscribed? or open the subscribe page.


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.