Implementing the OWASP API Security Top 10 in Node.js and PostgreSQL

Implementing the OWASP API Security Top 10 in Node.js and PostgreSQL

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. 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. 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: 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 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: 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: 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); 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 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. 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: 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 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 { 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 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 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 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 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 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 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 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. 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: 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: 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 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. 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) ); 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 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. 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. 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: 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 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 { 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. 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 { 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: 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. 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 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. 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. 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_token column, 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 WHERE clause 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. Sources OWASP Top 10 API Security Risks, 2023

Original Source

Read the full article at Hackernoon →

KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.