A
AJAY KUMAR
Guest
Most explanations of the OWASP API Security Top 10 stop at a definition and a one-line mitigation: "validate object ownership", "implement rate limiting". A developer who can recite those lines still ships the bug, because the line does not say where the check goes or what it looks like in code.
This guide takes one small API and, for each risk in the 2023 edition (the current one), shows the code that breaks, the code that fixes it, and a test that keeps it fixed. The risks that cause the most damage get the most space. The last three are shorter, but each still ends in something you can paste.
Everything below uses the same service: a multi-tenant store API on Node.js 20, Express and PostgreSQL (the
Every one of these routes has an obvious first implementation, and almost every obvious first implementation is on the OWASP list.
BOLA is an endpoint that checks who you are but not whether the object you asked for is yours. Here is the handler most people write first:
The ownership check is part of the lookup, so there is no window where the code holds a row it should not have. A missing order and someone else's order both return 404, which stops attackers from using 403 responses to map valid IDs.
Fix 1 works until someone adds a tenth endpoint in a hurry. Move data access behind a repository that cannot be called without a user:
Handlers never import
PostgreSQL row-level security enforces the same rule even if application code gets it wrong:
Two details decide whether this works. The application must connect as a role that is not a superuser, and
Write this test for every route that takes an object ID. The matrix near the end of this guide does it for all routes at once.
Authentication usually breaks in two places: token verification that accepts more than it should, and a login endpoint with no limit on guessing.
The common mistakes are calling
Pinning
A limit per IP alone fails against a botnet. A limit per account alone fails against password spraying, where one password is tried across thousands of accounts. Use both:
Three choices in that handler matter. The error message is identical for an unknown email and a wrong password. A dummy hash is verified when the user does not exist, so response time does not reveal which emails are registered. The account counter resets on success so real users are not punished for one typo.
The trade-off: a per-account limit lets an attacker lock a victim out for 15 minutes. If that matters for your product, replace the hard block with a CAPTCHA or an email challenge after five failures.
This risk has two halves: the API accepts fields the caller should not be able to write (mass assignment), and it returns fields the caller should not be able to read (excessive data exposure). Both come from passing whole objects through the handler.
Send
Rejecting unknown fields with a 400 is better than dropping them silently. Silent dropping hides probing from your logs and hides client bugs from your own team. A schema library with a strict mode gives you the same behaviour plus type checks.
Allowlists are per role, not per model. An admin editing a user may set
A serializer lists what goes out. A blocklist (
The second test asserts the exact key set. It fails when anyone adds a field to the response, which forces a decision instead of an accident.
"Add rate limiting" covers one of at least five limits an API needs. A request can be expensive because of how often it arrives, how large it is, how much it asks for, or how long it runs.
The
The default store is in memory, per process. With four instances, your real limit is four times the configured one. Use a shared store such as Redis in production.
Without the clamp,
Timeouts turn a slow endpoint from an outage into an error. Without
Operations that cost money per call (SMS, email, third-party lookups) need a spending limit as well as a request limit: a per-user daily cap and a billing alert at the provider.
BOLA is about which object; this one is about which action. A customer calls an admin endpoint and it works, usually because the only thing hiding that endpoint was the absence of a button in the UI.
The path says
Adding
A new admin route added to this router is protected before its author thinks about it. For routes with mixed roles, declare the permission next to the route and refuse to start if one is missing:
This table is also the input for the test matrix later in this guide.
Two places this check gets skipped in practice: alternate HTTP methods on the same path (
Assert the side effect as well as the status code. An endpoint that returns 403 after issuing the refund passes a status-only test.
Here the endpoint has no bug in the usual sense. It does what it was designed to do, and an attacker automates it: redeeming a coupon thousands of times, buying all the limited stock, creating accounts in bulk for referral credit. Authentication and authorization both pass.
The fix belongs in the business rule, enforced by the database, with a rate limit as a second layer.
The common broken version reads the coupon, checks
Rate limits per IP do little here because the traffic comes from many addresses with valid accounts. Limit per account, and add friction where abuse pays: verified email or phone before a coupon can be redeemed, and a delay or review step for high-value actions.
Raise the limiter's points for this test, or it will reject requests before the race is exercised.
Any endpoint that fetches a URL supplied by the caller lets the caller make requests from inside your network. Webhook testers, link previews, "import from URL" and PDF renderers are the usual places.
Point this at
A blocklist of strings such as
The rule that survives these: resolve the hostname, check the resulting IP addresses, then connect to the address you checked.
This version uses only Node's built-in modules.
Every resolved address is checked, not just the first, because a name can return one public and one private record.
Validating and then calling
Returning only the status code removes most of what an attacker gains even if a check is bypassed. The error message is generic on purpose: "connection refused" versus "timeout" is enough to port-scan an internal network.
Application checks are one layer. Run URL-fetching workers in a subnet with no route to internal services, and enforce egress rules at the firewall. On AWS, require IMDSv2, which rejects the simple GET that most SSRF payloads send to the metadata endpoint.
Add one integration test with a hostname you control that resolves to a private address. It is the case a list of literal IPs cannot cover.
These three are less about one handler and more about how the service is configured, tracked and connected. Each still has a concrete control.
The three misconfigurations that show up most in Express services are open CORS, verbose errors, and debug routes left on.
Never reflect the request's
Misconfiguration is drift, so test it against the deployed environment, not just in unit tests:
You cannot protect an endpoint you have forgotten. The typical finding is an old version (
Make the OpenAPI document the source of truth and fail the build when the code disagrees with it:
This uses the
Teams validate user input and then trust a partner's API completely. A third-party response is input too. It can be compromised, wrong, slow or very large.
Four controls are in that function: a timeout, no redirects, validation of type and range, and a return value built from checked fields instead of the raw object. Strings from a partner API need the same treatment as user strings before they reach SQL, HTML or a shell.
Individual tests cover the routes someone remembered to test. Authorization bugs live in the routes nobody remembered. A matrix test covers every route against every kind of caller, and fails when a new route is added without an entry.
The expected values are specific to this example API; yours come from your own access rules. Three things make this pattern worth the setup:
Around the matrix, a few cheaper checks catch other classes:
These bugs rarely look like bugs on the day they ship. They pass review and work for every legitimate user. The patterns below are the usual way they surface.
The common thread is that every one of these returns HTTP 200. Error-rate dashboards stay flat. What does show them:
Log the user ID, route, object ID and status for every authorization decision. Without those four fields, none of the signals above can be built.
One-per-user and limited-stock rules are enforced by database constraints
None of these controls is advanced. What makes them work is where they sit: in the query, the router, the schema and the build, so that the secure behaviour is the default and the insecure one takes extra effort.
This guide takes one small API and, for each risk in the 2023 edition (the current one), shows the code that breaks, the code that fixes it, and a test that keeps it fixed. The risks that cause the most damage get the most space. The last three are shorter, but each still ends in something you can paste.
The example API
Everything below uses the same service: a multi-tenant store API on Node.js 20, Express and PostgreSQL (the
pg driver). An auth middleware sets req.user to { id, role, tenantId }.| Route | Who should reach it |
|---|---|
GET /orders/:id | The customer who owns the order |
GET /orders | A signed-in customer, own orders only |
PATCH /users/me | The signed-in user, a few fields only |
POST /coupons/redeem | A signed-in customer, once per coupon |
POST /webhooks/test | A merchant; the server fetches a URL they supply |
POST /admin/refunds | Admins only |
Every one of these routes has an obvious first implementation, and almost every obvious first implementation is on the OWASP list.
API1: Broken Object Level Authorization (BOLA)
BOLA is an endpoint that checks who you are but not whether the object you asked for is yours. Here is the handler most people write first:
Code:
app.get('/orders/:id', requireAuth, async (req, res) => {
const { rows } = await db.query('SELECT * FROM orders WHERE id = $1', [req.params.id]);
if (!rows[0]) return res.sendStatus(404);
res.json(rows[0]);
});
requireAuth passes, so the route looks protected. Any signed-in customer can still read any order by changing the ID. Switching to UUIDs does not fix this: IDs leak through emails, logs, referrers and other endpoints.Fix 1: put ownership in the query
Code:
app.get('/orders/:id', requireAuth, async (req, res) => {
const { rows } = await db.query(
`SELECT id, status, total_cents, created_at
FROM orders
WHERE id = $1 AND customer_id = $2`,
[req.params.id, req.user.id]
);
if (!rows[0]) return res.sendStatus(404);
res.json(toPublicOrder(rows[0]));
});
The ownership check is part of the lookup, so there is no window where the code holds a row it should not have. A missing order and someone else's order both return 404, which stops attackers from using 403 responses to map valid IDs.
Fix 2: make the check impossible to forget
Fix 1 works until someone adds a tenth endpoint in a hurry. Move data access behind a repository that cannot be called without a user:
Code:
function ordersFor(user) {
return {
async findById(id) {
const { rows } = await db.query(
'SELECT * FROM orders WHERE id = $1 AND customer_id = $2',
[id, user.id]
);
return rows[0] ?? null;
},
async list({ limit, offset }) {
const { rows } = await db.query(
'SELECT * FROM orders WHERE customer_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3',
[user.id, limit, offset]
);
return rows;
},
};
}
// in a handler
const order = await ordersFor(req.user).findById(req.params.id);
Handlers never import
db. A lint rule that bans db imports outside the repository folder turns a code-review habit into a build failure.Fix 3: a second layer in the database
PostgreSQL row-level security enforces the same rule even if application code gets it wrong:
Code:
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
CREATE POLICY orders_owner ON orders
USING (customer_id = current_setting('app.user_id')::uuid);
Code:
await client.query('BEGIN');
await client.query("SELECT set_config('app.user_id', $1, true)", [req.user.id]);
// queries here only see this user's rows
await client.query('COMMIT');
Two details decide whether this works. The application must connect as a role that is not a superuser, and
FORCE is needed because table owners bypass policies by default.Test it
Code:
test('a customer cannot read another customer\'s order', async () => {
const order = await createOrder({ customer: alice });
const res = await request(app)
.get(`/orders/${order.id}`)
.set('Authorization', `Bearer ${tokenFor(bob)}`);
expect(res.status).toBe(404);
});
Write this test for every route that takes an object ID. The matrix near the end of this guide does it for all routes at once.
API2: Broken Authentication
Authentication usually breaks in two places: token verification that accepts more than it should, and a login endpoint with no limit on guessing.
Verify tokens strictly
The common mistakes are calling
jwt.decode (which checks nothing), verifying without pinning the algorithm, and skipping the issuer and audience checks. A token minted for another service then works on yours.
Code:
const jwt = require('jsonwebtoken');
function requireAuth(req, res, next) {
const token = (req.headers.authorization || '').replace(/^Bearer /, '');
try {
const payload = jwt.verify(token, PUBLIC_KEY, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
audience: 'orders-api',
maxAge: '15m',
});
req.user = { id: payload.sub, role: payload.role, tenantId: payload.tid };
next();
} catch {
res.sendStatus(401);
}
}
Pinning
algorithms blocks the classic attack where a token is signed with HS256 using your public key as the secret. maxAge caps token lifetime on the server side even if the issuer sets a long expiry. Keep access tokens short and use a refresh token that you can revoke.Limit login attempts per account and per IP
A limit per IP alone fails against a botnet. A limit per account alone fails against password spraying, where one password is tried across thousands of accounts. Use both:
Code:
const { RateLimiterRedis } = require('rate-limiter-flexible');
const byAccount = new RateLimiterRedis({
storeClient: redis, keyPrefix: 'login_acct',
points: 5, duration: 15 * 60, blockDuration: 15 * 60,
});
const byIp = new RateLimiterRedis({
storeClient: redis, keyPrefix: 'login_ip',
points: 50, duration: 15 * 60,
});
app.post('/login', async (req, res) => {
const email = String(req.body.email || '').toLowerCase();
try {
await Promise.all([byIp.consume(req.ip), byAccount.consume(email)]);
} catch {
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
}
const user = await findUserByEmail(email);
const hash = user ? user.password_hash : DUMMY_HASH;
const ok = (await argon2.verify(hash, String(req.body.password || ''))) && !!user;
if (!ok) return res.status(401).json({ error: 'Invalid email or password' });
await byAccount.delete(email);
res.json({ token: issueToken(user) });
});
Three choices in that handler matter. The error message is identical for an unknown email and a wrong password. A dummy hash is verified when the user does not exist, so response time does not reveal which emails are registered. The account counter resets on success so real users are not punished for one typo.
The trade-off: a per-account limit lets an attacker lock a victim out for 15 minutes. If that matters for your product, replace the hard block with a CAPTCHA or an email challenge after five failures.
Test it
Code:
test('rejects a token signed with the wrong algorithm', async () => {
const forged = jwt.sign({ sub: alice.id, role: 'admin' }, PUBLIC_KEY, { algorithm: 'HS256' });
const res = await request(app).get('/orders').set('Authorization', `Bearer ${forged}`);
expect(res.status).toBe(401);
});
test('the sixth failed login is throttled', async () => {
for (let i = 0; i < 5; i++) {
await request(app).post('/login').send({ email: alice.email, password: 'wrong' });
}
const res = await request(app).post('/login').send({ email: alice.email, password: 'wrong' });
expect(res.status).toBe(429);
});
API3: Broken Object Property Level Authorization
This risk has two halves: the API accepts fields the caller should not be able to write (mass assignment), and it returns fields the caller should not be able to read (excessive data exposure). Both come from passing whole objects through the handler.
Code:
app.patch('/users/me', requireAuth, async (req, res) => {
const user = await User.update(req.user.id, req.body);
res.json(user);
});
Send
{ "role": "admin" } and you are an admin. The response also returns password_hash, because the handler serializes the row as it came from the database.Fix the write side with an allowlist
Code:
const EDITABLE_BY_SELF = ['displayName', 'phone'];
function pickAllowed(body, allowed) {
const unknown = Object.keys(body).filter((k) => !allowed.includes(k));
if (unknown.length > 0) {
const err = new Error(`Unknown fields: ${unknown.join(', ')}`);
err.status = 400;
throw err;
}
return Object.fromEntries(allowed.filter((k) => k in body).map((k) => [k, body[k]]));
}
Rejecting unknown fields with a 400 is better than dropping them silently. Silent dropping hides probing from your logs and hides client bugs from your own team. A schema library with a strict mode gives you the same behaviour plus type checks.
Allowlists are per role, not per model. An admin editing a user may set
role; a user editing themselves may not. Keep one list per (role, action) pair.Fix the read side with explicit serializers
Code:
function toPublicUser(u) {
return { id: u.id, displayName: u.display_name, phone: u.phone };
}
app.patch('/users/me', requireAuth, async (req, res) => {
const changes = pickAllowed(req.body, EDITABLE_BY_SELF);
const user = await User.update(req.user.id, changes);
res.json(toPublicUser(user));
});
A serializer lists what goes out. A blocklist (
delete user.password_hash) lists what stays in, and it breaks the day someone adds a reset_token column. Filtering in the client is not a fix at all: the full object is still in the response.Test it
Code:
test('a user cannot promote themselves', async () => {
const res = await request(app)
.patch('/users/me')
.set('Authorization', `Bearer ${tokenFor(alice)}`)
.send({ role: 'admin' });
expect(res.status).toBe(400);
expect((await User.findById(alice.id)).role).toBe('customer');
});
test('responses contain only the public fields', async () => {
const res = await request(app)
.patch('/users/me')
.set('Authorization', `Bearer ${tokenFor(alice)}`)
.send({ displayName: 'Alice' });
expect(Object.keys(res.body).sort()).toEqual(['displayName', 'id', 'phone']);
});
The second test asserts the exact key set. It fails when anyone adds a field to the response, which forces a decision instead of an accident.
API4: Unrestricted Resource Consumption
"Add rate limiting" covers one of at least five limits an API needs. A request can be expensive because of how often it arrives, how large it is, how much it asks for, or how long it runs.
A concrete limit configuration
Code:
const { rateLimit } = require('express-rate-limit');
app.set('trust proxy', 1); // exactly one proxy in front of the app
// 1. Body size: reject large payloads before parsing them
app.use(express.json({ limit: '100kb' }));
// 2. Unauthenticated traffic: per IP
app.use(rateLimit({ windowMs: 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));
// 3. Authenticated traffic: per user, so shared office IPs are not punished
const perUser = rateLimit({
windowMs: 60_000,
limit: 300,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.user.id,
});
app.use('/orders', requireAuth, perUser);
The
trust proxy line is where this most often goes wrong. Without it, every request appears to come from your load balancer and all users share one bucket. Set too permissively, clients can spoof X-Forwarded-For and get a fresh bucket per request. Set it to the real number of proxies in front of the app.The default store is in memory, per process. With four instances, your real limit is four times the configured one. Use a shared store such as Redis in production.
Cap what a single request can ask for
Code:
app.get('/orders', requireAuth, perUser, async (req, res) => {
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 100);
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
const orders = await ordersFor(req.user).list({ limit, offset });
res.json(orders.map(toPublicOrder));
});
Without the clamp,
?limit=10000000 is a single request that passes every rate limit and still exhausts memory. The same applies to batch endpoints, GraphQL query depth, and file uploads: each needs its own maximum.Cap how long work can run
Code:
const pool = new Pool({
max: 20,
statement_timeout: 5_000, // the database cancels slow queries
connectionTimeoutMillis: 2_000, // fail fast when the pool is exhausted
});
const server = app.listen(3000);
server.requestTimeout = 15_000;
server.headersTimeout = 10_000;
Timeouts turn a slow endpoint from an outage into an error. Without
statement_timeout, twenty slow queries occupy the whole pool and every other endpoint stalls behind them.Operations that cost money per call (SMS, email, third-party lookups) need a spending limit as well as a request limit: a per-user daily cap and a billing alert at the provider.
Test it
Code:
test('page size is capped at 100', async () => {
await createOrders({ customer: alice, count: 150 });
const res = await request(app)
.get('/orders?limit=100000')
.set('Authorization', `Bearer ${tokenFor(alice)}`);
expect(res.body.length).toBe(100);
});
test('oversized bodies are rejected', async () => {
const res = await request(app)
.patch('/users/me')
.set('Authorization', `Bearer ${tokenFor(alice)}`)
.send({ displayName: 'x'.repeat(200_000) });
expect(res.status).toBe(413);
});
API5: Broken Function Level Authorization
BOLA is about which object; this one is about which action. A customer calls an admin endpoint and it works, usually because the only thing hiding that endpoint was the absence of a button in the UI.
Code:
app.post('/admin/refunds', requireAuth, async (req, res) => {
await issueRefund(req.body.orderId, req.body.amountCents);
res.sendStatus(204);
});
The path says
admin. Nothing in the code does.Fix: deny by default at the router
Adding
requireRole('admin') to this one handler fixes this one handler. The durable fix makes a missing check fail closed:
Code:
function requireRole(...roles) {
return (req, res, next) =>
roles.includes(req.user?.role) ? next() : res.sendStatus(403);
}
const admin = express.Router();
admin.use(requireAuth, requireRole('admin')); // applies to everything below
admin.post('/refunds', refundHandler);
admin.get('/users', listUsersHandler);
app.use('/admin', admin);
A new admin route added to this router is protected before its author thinks about it. For routes with mixed roles, declare the permission next to the route and refuse to start if one is missing:
Code:
const routes = [
{ method: 'get', path: '/orders/:id', roles: ['customer'], handler: getOrder },
{ method: 'post', path: '/admin/refunds', roles: ['admin'], handler: refundHandler },
];
for (const r of routes) {
if (!Array.isArray(r.roles) || r.roles.length === 0) {
throw new Error(`Route ${r.method} ${r.path} declares no roles`);
}
app[r.method](r.path, requireAuth, requireRole(...r.roles), r.handler);
}
This table is also the input for the test matrix later in this guide.
Two places this check gets skipped in practice: alternate HTTP methods on the same path (
GET is checked, DELETE is not), and older API versions (/v2/admin/users is protected, /v1/admin/users still runs the old code).Test it
Code:
test('a customer cannot issue a refund', async () => {
const res = await request(app)
.post('/admin/refunds')
.set('Authorization', `Bearer ${tokenFor(alice)}`)
.send({ orderId: order.id, amountCents: 100 });
expect(res.status).toBe(403);
expect(await refundsFor(order.id)).toHaveLength(0);
});
Assert the side effect as well as the status code. An endpoint that returns 403 after issuing the refund passes a status-only test.
API6: Unrestricted Access to Sensitive Business Flows
Here the endpoint has no bug in the usual sense. It does what it was designed to do, and an attacker automates it: redeeming a coupon thousands of times, buying all the limited stock, creating accounts in bulk for referral credit. Authentication and authorization both pass.
The fix belongs in the business rule, enforced by the database, with a rate limit as a second layer.
Code:
CREATE TABLE coupon_redemptions (
coupon_id uuid NOT NULL REFERENCES coupons(id),
customer_id uuid NOT NULL REFERENCES customers(id),
redeemed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (coupon_id, customer_id)
);
Code:
const redeemLimiter = new RateLimiterRedis({
storeClient: redis, keyPrefix: 'redeem', points: 5, duration: 60 * 60,
});
app.post('/coupons/redeem', requireAuth, async (req, res) => {
try {
await redeemLimiter.consume(req.user.id);
} catch {
return res.sendStatus(429);
}
const client = await pool.connect();
try {
await client.query('BEGIN');
const claimed = await client.query(
`UPDATE coupons SET remaining = remaining - 1
WHERE code = $1 AND remaining > 0 AND expires_at > now()
RETURNING id`,
[String(req.body.code)]
);
if (claimed.rowCount === 0) {
await client.query('ROLLBACK');
return res.status(409).json({ error: 'Coupon is not available' });
}
const inserted = await client.query(
`INSERT INTO coupon_redemptions (coupon_id, customer_id)
VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[claimed.rows[0].id, req.user.id]
);
if (inserted.rowCount === 0) {
await client.query('ROLLBACK');
return res.status(409).json({ error: 'Coupon already redeemed' });
}
await client.query('COMMIT');
res.sendStatus(204);
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
});
The common broken version reads the coupon, checks
remaining > 0 in JavaScript, then writes. Fifty parallel requests all read the same value and all pass the check. The single UPDATE ... WHERE remaining > 0 and the primary key move both rules into the database, where concurrent requests cannot both win.Rate limits per IP do little here because the traffic comes from many addresses with valid accounts. Limit per account, and add friction where abuse pays: verified email or phone before a coupon can be redeemed, and a delay or review step for high-value actions.
Test it
Code:
test('fifty parallel redemptions succeed exactly once', async () => {
const results = await Promise.all(
Array.from({ length: 50 }, () =>
request(app)
.post('/coupons/redeem')
.set('Authorization', `Bearer ${tokenFor(alice)}`)
.send({ code: 'WELCOME10' })
)
);
expect(results.filter((r) => r.status === 204)).toHaveLength(1);
});
Raise the limiter's points for this test, or it will reject requests before the race is exercised.
API7: Server Side Request Forgery (SSRF)
Any endpoint that fetches a URL supplied by the caller lets the caller make requests from inside your network. Webhook testers, link previews, "import from URL" and PDF renderers are the usual places.
Code:
app.post('/webhooks/test', requireAuth, async (req, res) => {
const response = await fetch(req.body.url);
res.json({ status: response.status, body: await response.text() });
});
Point this at
http://169.254.169.254/latest/meta-data/ on a cloud VM and it returns instance metadata, which on a default configuration can include credentials. Point it at http://localhost:9200 and it reads your internal Elasticsearch.Why the obvious filters fail
A blocklist of strings such as
localhost and 127.0.0.1 misses every other spelling of the same address. All of these reach loopback:| Input | Why a string filter misses it |
|---|---|
https://2130706433/ | Decimal form of 127.0.0.1 |
https://0x7f.1/ | Hex and short form |
https://[::ffff:127.0.0.1]/ | IPv4-mapped IPv6 |
https://internal.attacker.example/ | A public name whose DNS record points to 10.0.0.5 |
https://safe.example/redirect | A public page that redirects to an internal address |
The rule that survives these: resolve the hostname, check the resulting IP addresses, then connect to the address you checked.
Validation logic
This version uses only Node's built-in modules.
Code:
const dns = require('node:dns/promises');
const net = require('node:net');
const https = require('node:https');
const blocked = new net.BlockList();
for (const [addr, 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.0.0.0', 24], ['192.168.0.0', 16],
['198.18.0.0', 15], ['224.0.0.0', 4], ['240.0.0.0', 4],
]) blocked.addSubnet(addr, prefix, 'ipv4');
for (const [addr, prefix] of [
['::', 127], ['64:ff9b::', 96], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8],
]) blocked.addSubnet(addr, prefix, 'ipv6');
async function assertPublicUrl(raw) {
const url = new URL(raw);
if (url.protocol !== 'https:') throw new Error('https only');
if (url.username || url.password) throw new Error('credentials not allowed');
if (url.port && url.port !== '443') throw new Error('port not allowed');
const host = url.hostname.replace(/^\[|\]$/g, '');
const addresses = await dns.lookup(host, { all: true });
if (addresses.length === 0) throw new Error('host did not resolve');
for (const { address, family } of addresses) {
if (blocked.check(address, family === 6 ? 'ipv6' : 'ipv4')) {
throw new Error('address not allowed');
}
}
return { url, address: addresses[0].address, family: addresses[0].family };
}
Every resolved address is checked, not just the first, because a name can return one public and one private record.
BlockList also matches IPv4-mapped IPv6 addresses against the IPv4 rules, which closes the ::ffff:127.0.0.1 route.Connect to the address you validated
Validating and then calling
fetch(url) resolves the name a second time. An attacker who controls the DNS server answers the first lookup with a public IP and the second with 127.0.0.1. This is DNS rebinding. Pin the connection to the checked address:
Code:
function pinnedLookup(address, family) {
return (hostname, options, callback) => {
if (options && options.all) return callback(null, [{ address, family }]);
callback(null, address, family);
};
}
async function safeFetch(raw, { maxBytes = 64 * 1024, timeoutMs = 5000 } = {}) {
const { url, address, family } = await assertPublicUrl(raw);
return new Promise((resolve, reject) => {
const req = https.request(
url,
{ method: 'GET', lookup: pinnedLookup(address, family), timeout: timeoutMs },
(res) => {
const chunks = [];
let size = 0;
res.on('data', (chunk) => {
size += chunk.length;
if (size > maxBytes) return req.destroy(new Error('response too large'));
chunks.push(chunk);
});
res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks) }));
}
);
req.on('timeout', () => req.destroy(new Error('timeout')));
req.on('error', reject);
req.end();
});
}
https.request does not follow redirects, so a 302 to an internal address comes back as a 302 and nothing more. If you need redirects, follow them yourself and run assertPublicUrl on every hop, with a cap of three or so. The TLS certificate is still verified against the original hostname, because only the DNS step is overridden.Do not hand the response back
Code:
app.post('/webhooks/test', requireAuth, requireRole('merchant'), async (req, res) => {
try {
const { status } = await safeFetch(String(req.body.url));
res.json({ delivered: status >= 200 && status < 300, status });
} catch {
res.status(400).json({ error: 'URL could not be reached' });
}
});
Returning only the status code removes most of what an attacker gains even if a check is bypassed. The error message is generic on purpose: "connection refused" versus "timeout" is enough to port-scan an internal network.
Add a network layer
Application checks are one layer. Run URL-fetching workers in a subnet with no route to internal services, and enforce egress rules at the firewall. On AWS, require IMDSv2, which rejects the simple GET that most SSRF payloads send to the metadata endpoint.
Test it
Code:
test.each([
'http://169.254.169.254/latest/meta-data/',
'https://169.254.169.254/',
'https://localhost/',
'https://127.0.0.1/',
'https://2130706433/',
'https://0x7f.1/',
'https://[::1]/',
'https://[::ffff:127.0.0.1]/',
'https://10.0.0.5/',
'https://192.168.1.1/',
'https://[fd00::1]/',
'https://user:[email protected]/',
'https://example.com:8443/',
'file:///etc/passwd',
])('rejects %s', async (url) => {
await expect(assertPublicUrl(url)).rejects.toThrow();
});
Add one integration test with a hostname you control that resolves to a private address. It is the case a list of literal IPs cannot cover.
API8, API9 and API10: the shorter three
These three are less about one handler and more about how the service is configured, tracked and connected. Each still has a concrete control.
API8: Security Misconfiguration
The three misconfigurations that show up most in Express services are open CORS, verbose errors, and debug routes left on.
Code:
const ALLOWED_ORIGINS = new Set(['https://app.example.com']);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.set('Access-Control-Allow-Origin', origin);
res.set('Access-Control-Allow-Credentials', 'true');
res.set('Vary', 'Origin');
}
next();
});
// last middleware: one error shape for every failure
app.use((err, req, res, next) => {
const status = err.status && err.status < 500 ? err.status : 500;
if (status === 500) logger.error({ err, requestId: req.id });
res.status(status).json({
error: status === 500 ? 'Internal error' : err.message,
requestId: req.id,
});
});
Never reflect the request's
Origin header back without checking it against a list. That is equivalent to a wildcard, and it works with credentials, which a literal * does not. The error handler returns a request ID instead of a stack trace: support can find the log line, and the caller learns nothing about your file paths or SQL.Misconfiguration is drift, so test it against the deployed environment, not just in unit tests:
Code:
test('errors do not leak internals', async () => {
const res = await request(app).get('/orders/not-a-uuid').set('Authorization', `Bearer ${tokenFor(alice)}`);
expect(JSON.stringify(res.body)).not.toMatch(/at \w+ \(|node_modules|SELECT/);
});
test('unknown origins get no CORS header', async () => {
const res = await request(app).get('/orders').set('Origin', 'https://evil.example');
expect(res.headers['access-control-allow-origin']).toBeUndefined();
});
API9: Improper Inventory Management
You cannot protect an endpoint you have forgotten. The typical finding is an old version (
/v1), a staging host with production data, or a debug route, none of which received the fixes applied to the main API.Make the OpenAPI document the source of truth and fail the build when the code disagrees with it:
Code:
test('every registered route is documented', () => {
const documented = new Set(
Object.entries(openapi.paths).flatMap(([path, methods]) =>
Object.keys(methods).map((m) => `${m} ${path.replace(/\{(\w+)\}/g, ':$1')}`)
)
);
const undocumented = routes
.map((r) => `${r.method} ${r.path}`)
.filter((r) => !documented.has(r));
expect(undocumented).toEqual([]);
});
This uses the
routes table from API5, which is a second reason to declare routes as data. For retired versions, set a removal date, send Deprecation and Sunset response headers until then, and delete the code on that date. A version that is "deprecated" but still deployed is a live API.API10: Unsafe Consumption of APIs
Teams validate user input and then trust a partner's API completely. A third-party response is input too. It can be compromised, wrong, slow or very large.
Code:
async function getShippingRate(orderId) {
const res = await fetch(`https://rates.partner.example/v1/quote/${encodeURIComponent(orderId)}`, {
signal: AbortSignal.timeout(3000),
redirect: 'error',
});
if (!res.ok) throw new Error(`rates API returned ${res.status}`);
const data = await res.json();
const cents = data?.amountCents;
if (!Number.isInteger(cents) || cents < 0 || cents > 1_000_000) {
throw new Error('rates API returned an invalid amount');
}
if (!['INR', 'USD', 'EUR'].includes(data.currency)) {
throw new Error('rates API returned an unknown currency');
}
return { amountCents: cents, currency: data.currency };
}
Four controls are in that function: a timeout, no redirects, validation of type and range, and a return value built from checked fields instead of the raw object. Strings from a partner API need the same treatment as user strings before they reach SQL, HTML or a shell.
Code:
test('rejects a negative rate from the partner', async () => {
mockRatesApi.reply(200, { amountCents: -500, currency: 'INR' });
await expect(getShippingRate(order.id)).rejects.toThrow('invalid amount');
});
Testing strategy: an authorization matrix in CI
Individual tests cover the routes someone remembered to test. Authorization bugs live in the routes nobody remembered. A matrix test covers every route against every kind of caller, and fails when a new route is added without an entry.
Code:
const actors = {
anonymous: null,
owner: () => tokenFor(alice), // owns the fixture objects
otherCustomer: () => tokenFor(bob), // same role, different user
otherTenant: () => tokenFor(carol), // different tenant
admin: () => tokenFor(adminUser),
};
const matrix = [
// method, path, anonymous, owner, otherCustomer, otherTenant, admin
['get', '/orders/:orderId', 401, 200, 404, 404, 403],
['get', '/orders', 401, 200, 200, 200, 200],
['patch', '/users/me', 401, 200, 200, 200, 200],
['post', '/coupons/redeem', 401, 204, 204, 409, 403],
['post', '/webhooks/test', 401, 403, 403, 403, 403],
['post', '/admin/refunds', 401, 403, 403, 403, 204],
];
describe.each(matrix)('%s %s', (method, path, ...expected) => {
test.each(Object.keys(actors).map((name, i) => [name, expected[i]]))(
'as %s returns %i',
async (actorName, status) => {
const req = request(app)[method](path.replace(':orderId', fixtures.aliceOrder.id));
const token = actors[actorName]?.();
if (token) req.set('Authorization', `Bearer ${token}`);
const res = await req.send(fixtures.bodyFor(method, path));
expect(res.status).toBe(status);
}
);
});
test('every route has a matrix row', () => {
const covered = new Set(matrix.map(([m, p]) => `${m} ${p.replace(':orderId', ':id')}`));
const missing = routes.map((r) => `${r.method} ${r.path}`).filter((r) => !covered.has(r));
expect(missing).toEqual([]);
});
The expected values are specific to this example API; yours come from your own access rules. Three things make this pattern worth the setup:
- The "other customer" column is the BOLA test. Same role, valid token, wrong owner. It is the column most test suites lack.
- The last test is the important one. A new route with no row fails the build, so the person adding the route has to state who may call it.
- The matrix is readable by non-developers. A product owner can review a table of who can do what and spot a wrong cell.
Around the matrix, a few cheaper checks catch other classes:
| Check | Catches | When it runs |
|---|---|---|
| Lint rule banning raw db imports in handlers | Unscoped queries (API1) | Every commit |
| Response key-set assertions | Leaked fields (API3) | Every commit |
| Route list compared with OpenAPI | Shadow and stale endpoints (API9) | Every commit |
| Dynamic scan with two user accounts against staging | Whatever the above missed | Nightly |
How these fail in production
These bugs rarely look like bugs on the day they ship. They pass review and work for every legitimate user. The patterns below are the usual way they surface.
- The mobile app was the only client, until it was not. The app only ever requests the signed-in user's orders, so nobody notices the server does not check. Someone proxies the app's traffic, changes one ID, and gets another user's order. Nothing errors, so nothing alerts.
- The check exists on the new endpoint only. Version 2 was written carefully. Version 1 still runs for an old client, shares the same database, and has none of the fixes.
- The limit was per instance. Rate limiting worked in testing with one process. Production runs twelve behind a load balancer, each with its own in-memory counter.
- The internal tool was never meant to be public. An admin route had no role check because it sat on a private hostname. A routing change exposed it.
- The response grew. A serializer returned the whole user row. Months later a migration added a
reset_tokencolumn, and the API began returning it. - The feature was the vulnerability. "Import from URL" shipped as specified. The specification never said which URLs.
The common thread is that every one of these returns HTTP 200. Error-rate dashboards stay flat. What does show them:
| Signal | What it suggests |
|---|---|
| One account receiving many 404s across sequential or varied object IDs | Enumeration against object-level checks |
| 403s on admin paths from non-admin accounts | Probing for function-level gaps |
| Many accounts failing login with the same password | Password spraying |
| One account hitting a business endpoint far above the median | Automated abuse of a sensitive flow |
| Outbound requests from the API to private or metadata addresses | SSRF attempts |
Log the user ID, route, object ID and status for every authorization decision. Without those four fields, none of the signals above can be built.
A checklist to take away
- Every query for a user-owned object includes the owner in the
WHEREclause - Handlers cannot reach the database except through a user-scoped repository
- JWT verification pins the algorithm, issuer and audience
- Login is limited per account and per IP, with identical errors for unknown users
- Request bodies go through a per-role allowlist; unknown fields return 400
- Responses are built by serializers that list fields explicitly
- Body size, page size, query time and request time each have a maximum
- Rate-limit counters live in a shared store
- Admin routers apply the role check once, at the router
One-per-user and limited-stock rules are enforced by database constraints
- Outbound fetches resolve, check and pin the IP, and do not follow redirects
- Errors return a request ID, never a stack trace
- CI fails when a route is missing from the OpenAPI document or the authorization matrix
- Third-party responses are validated and time-limited like user input
None of these controls is advanced. What makes them work is where they sit: in the query, the router, the schema and the build, so that the secure behaviour is the default and the insecure one takes extra effort.