API Endpoint Security: Controls and Negative Tests

API endpoint security means every route your API exposes enforces its own access rules, and you can prove it. List every endpoint, including the ones the spec forgot. For each one, decide who may call it, which objects and fields they may touch, what input is valid, and what the refusal looks like. Then test each refusal with a request that should fail, and assert the exact status code.
Every file on this page ran as printed in an empty folder with Node 26.10.0 and its built-in test runner, no packages. Every output block is copied from that run's log.
Qodex runs attack chains against your preview on every pull request, from flows it has already tested, with two real accounts. See Qodex security testing or try it on your API.
What API endpoint security covers
Here an endpoint means one method on one route, such as GET /v1/reports/:id. For the anatomy of a URL and the difference between an endpoint and an API, see what is an API endpoint. "Endpoint security" also names a category of laptop and phone protection software. That is a different subject, and this page does not cover it.
The unit matters because each route needs its own authorization decision. Three of the OWASP API Security Top 10 2023 risks are authorization failures at that level. API1 is broken object level authorization, API3 broken object property level authorization, and API5 broken function level authorization. A fourth, API9 improper inventory management, is about routes nobody is tracking. Source: OWASP API Security Top 10 2023, read 4 October 2026.
A gateway in front of the API can authenticate callers and limit traffic. It does not hold your ownership data, such as which reports belong to Ana. That decision lives in the handler for each endpoint, which is why the checks below run against the service itself.
Start with an inventory of every endpoint
You cannot test a route you do not know exists. OWASP's API9 guidance asks you to inventory every API host and record its environment, who should reach it over the network, and its API version. It also lists missing retirement plans as a weakness. Source: OWASP API9:2023, read 4 October 2026.
For a single service, build the list from four places and compare them:
The spec. The routes your OpenAPI file documents.
The router. The routes the code actually registers. This is the list that matters, because it is what answers requests.
The gateway. The routes it forwards, which can include old versions.
Traffic. The paths that show up in access logs.
A route in the router but missing from the spec is the dangerous case. It is deployed, and its documentation is missing. Record an owner, the version and a retirement date for each entry. For the wider process, see our guide to building an API inventory.
Build a control matrix per endpoint
For each row in the inventory, answer the same questions. The table pairs each control with the OWASP risk it maps to and the negative test that proves it.
| Control | Question per endpoint | Source | Refusal | Negative test |
|---|---|---|---|---|
| Authentication | Must the caller be signed in? | API2 | 401 | Call with no token and with a made-up one |
| Object access | Can this caller touch this object? | API1 | 404 or 403 | Request another user's object id |
| Function access | Can this role call this function at all? | API5 | 403 | Call an admin route as a member |
| Field access | Which fields can this caller read or write? | API3 | 400 | Send a field the caller must not set |
| Input | Is the body the right type, size and shape? | REST cheat sheet | 400, 413 or 415 | Send a malformed or oversized body |
| Rate | How many calls per caller per window? | API4 | 429 | Send one call past the limit |
Five rows map to OWASP API Security Top 10 2023 risks. The input row comes from the OWASP REST Security Cheat Sheet, which says to reject oversized requests with 413 and unsupported content types with 415. Source: OWASP REST Security Cheat Sheet, read 4 October 2026.
The OWASP Authorization Cheat Sheet adds two rules that apply to every row: deny by default, and check permissions on every request. Its guidance on access control says to "Perform access control checks on every request for the specific object or functionality being accessed". Source: OWASP Authorization Cheat Sheet, read 4 October 2026.
API3 covers both reading fields a caller should not see and writing fields they should not set, such as their own role. The fix is an allowlist of writable fields per endpoint. The Mass Assignment Cheat Sheet warns against relying on a block-list alone, because "a new or overlooked sensitive field remains bindable". Sources: OWASP API3:2023 and the Mass Assignment Cheat Sheet, read 4 October 2026. For the risks behind each row, see our OWASP API Top 10 guide.
Choose the refusal: 401, 403, 404 or 429
A negative test needs an exact expected status, or a 500 from a crash counts as a pass. These are the codes the HTTP standards define for refusals.
401 Unauthorized means the request "lacks valid authentication credentials for the target resource". The server "MUST send a WWW-Authenticate header field" with at least one challenge. Source: RFC 9110, section 15.5.2.
403 Forbidden means the server "understood the request but refuses to fulfill it". Credentials, if sent, were insufficient, though a request can also be forbidden for other reasons. Source: RFC 9110, section 15.5.4.
404 Not Found also covers a server that "is not willing to disclose" that a resource exists. Section 15.5.4 lets a server that wants to hide a forbidden resource answer 404 instead of 403. Source: RFC 9110, section 15.5.5.
429 Too Many Requests means "the user has sent too many requests in a given amount of time". It may carry a
Retry-Afterheader, and the RFC does not define how the server counts or identifies the user. Source: RFC 6585, section 4.
All four were read 4 October 2026. The example below answers 404 when a member asks for another member's report, so the response does not confirm the report exists. It answers 403 when a member calls an admin route, because the route itself is not a secret. Pick one policy per case and write it into the test, so a change of behaviour shows up as a failure.
Run an inventory-driven test
The demo is a small API with five routes. One of them, /v1/legacy/audit, is left over from an earlier version: it is not in the OpenAPI file, and it is still deployed. Save this as app.mjs:
// app.mjs: a small API with five routes, one of them left over from v0. Node built-ins only.
import { createServer } from 'node:http';
import { randomBytes, timingSafeEqual } from 'node:crypto';
// Test identities. Tokens are random per process; the server decides each role.
export const users = {
ana: { id: 'u1', role: 'member', displayName: 'Ana', token: randomBytes(16).toString('hex') },
bo: { id: 'u2', role: 'member', displayName: 'Bo', token: randomBytes(16).toString('hex') },
admin: { id: 'u9', role: 'admin', displayName: 'Admin', token: randomBytes(16).toString('hex') },
};
const reports = new Map([['rep_1', { id: 'rep_1', owner: 'u1', title: 'Q3 usage' }]]);
const auditLog = [{ at: '2026-10-01T09:00:00Z', actor: 'u9', action: 'role.change' }];
function send(res, status, body) {
const headers = { 'content-type': 'application/json' };
if (status === 401) headers['www-authenticate'] = 'Bearer';
res.writeHead(status, headers);
res.end(JSON.stringify(body));
}
const MAX_BODY = 10_000; // bytes
const TOO_LARGE = Symbol('too large');
const CUT_OFF = Symbol('cut off');
async function readJson(req) {
let raw = '', size = 0;
try {
for await (const chunk of req) { size += chunk.length; if (size <= MAX_BODY) raw += chunk; }
} catch {
return CUT_OFF; // the client stopped sending mid-body
}
if (size > MAX_BODY) return TOO_LARGE;
try { return JSON.parse(raw); } catch { return null; }
}
const sameToken = (a, b) => {
const x = Buffer.from(a), y = Buffer.from(b);
return x.length === y.length && timingSafeEqual(x, y);
};
const signedIn = (handler) => (req, res, ctx) =>
ctx.user ? handler(req, res, ctx) : send(res, 401, { error: 'sign in' });
const adminOnly = (handler) => signedIn((req, res, ctx) =>
ctx.user.role === 'admin' ? handler(req, res, ctx) : send(res, 403, { error: 'admins only' }));
// Every route the server answers, in one table the tests can read.
export const routes = [
{ method: 'GET', path: '/v1/health', handler: (req, res) => send(res, 200, { ok: true }) },
{ method: 'GET', path: '/v1/reports/:id', handler: signedIn((req, res, { user, id }) => {
const report = reports.get(id);
if (!report || report.owner !== user.id) return send(res, 404, { error: 'not found' });
send(res, 200, report);
}) },
{ method: 'PATCH', path: '/v1/profile', handler: signedIn(async (req, res, { user }) => {
const body = await readJson(req);
if (body === TOO_LARGE) return send(res, 413, { error: 'body too large' });
if (body === CUT_OFF) return send(res, 400, { error: 'incomplete body' });
const writable = ['displayName'];
const isObject = body !== null && typeof body === 'object' && !Array.isArray(body);
if (!isObject || Object.keys(body).some((k) => !writable.includes(k)) || typeof body.displayName !== 'string') {
return send(res, 400, { error: 'only displayName can change' });
}
user.displayName = body.displayName.slice(0, 60);
send(res, 200, { id: user.id, displayName: user.displayName });
}) },
{ method: 'GET', path: '/v1/admin/audit', handler: adminOnly((req, res) => send(res, 200, auditLog)) },
// Left over from v0. Not in the OpenAPI file, still deployed.
{ method: 'GET', path: '/v1/legacy/audit', handler: signedIn((req, res) => send(res, 200, auditLog)) },
];
export function start() {
const server = createServer((req, res) => {
const { pathname } = new URL(req.url, 'http://localhost');
const token = (req.headers.authorization ?? '').replace(/^Bearer /, '');
const user = Object.values(users).find((u) => sameToken(u.token, token));
for (const route of routes) {
const match = new RegExp('^' + route.path.replace(':id', '(\\w+)') + '$').exec(pathname);
if (match && req.method === route.method) return route.handler(req, res, { user, id: match[1] });
}
send(res, 404, { error: 'no such route' });
});
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server)));
}
The test file holds the inventory as data: at least one row per documented endpoint, with the status each caller should get. The first test compares that inventory with the routes the server really answers. The loop then calls every endpoint as every caller. Save this as endpoints.test.mjs:
// endpoints.test.mjs: one row per endpoint, the expected status for each caller.
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { connect } from 'node:net';
import { start, users, routes } from './app.mjs';
const inventory = [
{ method: 'GET', path: '/v1/health', url: '/v1/health',
expect: { anonymous: 200, ana: 200, bo: 200, admin: 200 } },
{ method: 'GET', path: '/v1/reports/:id', url: '/v1/reports/rep_1',
expect: { anonymous: 401, ana: 200, bo: 404, admin: 404 } },
{ method: 'PATCH', path: '/v1/profile', url: '/v1/profile', body: { role: 'admin' },
expect: { anonymous: 401, ana: 400, bo: 400, admin: 400 } },
{ method: 'PATCH', path: '/v1/profile', url: '/v1/profile', body: { displayName: 'New name' },
expect: { anonymous: 401, ana: 200, bo: 200, admin: 200 } },
{ method: 'GET', path: '/v1/admin/audit', url: '/v1/admin/audit',
expect: { anonymous: 401, ana: 403, bo: 403, admin: 200 } },
];
// Sends half a request body, then hangs up, the way a dropped mobile connection does.
const cutOff = (port, path, header = '') => new Promise((resolve) => {
const socket = connect(port, '127.0.0.1', () => {
socket.write(`POST ${path} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\n`
+ `Content-Length: 100\r\n${header}\r\n{"half":`);
setTimeout(() => { socket.destroy(); setTimeout(resolve, 100); }, 50);
});
});
let server, base;
before(async () => {
server = await start();
base = `http://127.0.0.1:${server.address().port}`;
});
after(() => server.close());
test('every route the server answers is in the inventory', () => {
const served = routes.map((r) => `${r.method} ${r.path}`).sort();
const listed = [...new Set(inventory.map((r) => `${r.method} ${r.path}`))].sort();
assert.deepEqual(listed, served);
});
for (const row of inventory) {
const sent = row.body ? ` with ${Object.keys(row.body)}` : '';
test(`${row.method} ${row.path}${sent} answers each caller as expected`, async () => {
const actual = {};
for (const caller of Object.keys(row.expect)) {
const headers = { 'content-type': 'application/json' };
if (caller !== 'anonymous') headers.authorization = `Bearer ${users[caller].token}`;
const res = await fetch(base + row.url, {
method: row.method,
headers,
body: row.body ? JSON.stringify(row.body) : undefined,
});
actual[caller] = res.status;
}
assert.deepEqual(actual, row.expect);
});
}
test('a rejected profile change leaves the role as it was', async () => {
await fetch(`${base}/v1/profile`, {
method: 'PATCH',
headers: { 'content-type': 'application/json', authorization: `Bearer ${users.ana.token}` },
body: JSON.stringify({ role: 'admin', displayName: 'Ana' }),
});
assert.equal(users.ana.role, 'member');
});
test('a token with non-ASCII bytes is refused, not a crash', async () => {
const res = await fetch(`${base}/v1/reports/rep_1`, { headers: { authorization: `Bearer ${'\u00e9'.repeat(32)}` } });
assert.equal(res.status, 401);
assert.equal((await fetch(`${base}/v1/health`)).status, 200);
});
test('a body cut off mid-stream does not take the server down', async () => {
await cutOff(server.address().port, '/v1/profile', `Authorization: Bearer ${users.ana.token}\r\n`);
assert.equal((await fetch(`${base}/v1/health`)).status, 200);
});
The rows encode the rules from the matrix. Bo gets 404 for Ana's report. Members get 403 on the admin route. Every signed-in caller gets 400 for trying to set role and 200 for changing its display name, so a route that refuses everything would fail. The last test checks the role really did not change. Bodies over 10,000 bytes get 413, and tokens are compared in constant time. The last two tests send a token with non-ASCII bytes and a body that stops halfway, and check the server still answers. Run it:
$ node --test
✖ every route the server answers is in the inventory (4.015792ms)
✔ GET /v1/health answers each caller as expected (12.202375ms)
✔ GET /v1/reports/:id answers each caller as expected (2.938375ms)
✔ PATCH /v1/profile with role answers each caller as expected (3.708417ms)
✔ PATCH /v1/profile with displayName answers each caller as expected (1.584334ms)
✔ GET /v1/admin/audit answers each caller as expected (1.219292ms)
✔ a rejected profile change leaves the role as it was (0.500792ms)
✔ a token with non-ASCII bytes is refused, not a crash (1.071667ms)
✔ a body cut off mid-stream does not take the server down (154.387875ms)
ℹ tests 9
ℹ suites 0
ℹ pass 8
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 290.45075
✖ failing tests:
test at endpoints.test.mjs:36:1
✖ every route the server answers is in the inventory (4.015792ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected
[
'GET /v1/admin/audit',
'GET /v1/health',
- 'GET /v1/legacy/audit',
'GET /v1/reports/:id',
'PATCH /v1/profile'
]
...
Every documented endpoint passes. The inventory check fails, and its diff names the route the inventory missed: GET /v1/legacy/audit. We trimmed the stack trace from that output.
Fix the forgotten route and keep the test
Add the missing row to the inventory, with the same rule as the current audit route: members get 403.
{ method: 'GET', path: '/v1/legacy/audit', url: '/v1/legacy/audit',
expect: { anonymous: 401, ana: 403, bo: 403, admin: 200 } },
Run the tests again. Now the inventory matches the router, and the new row shows what the legacy route actually does:
$ node --test
✔ every route the server answers is in the inventory (3.149375ms)
✔ GET /v1/health answers each caller as expected (10.468083ms)
✔ GET /v1/reports/:id answers each caller as expected (1.7585ms)
✔ PATCH /v1/profile with role answers each caller as expected (4.325833ms)
✔ PATCH /v1/profile with displayName answers each caller as expected (1.507791ms)
✔ GET /v1/admin/audit answers each caller as expected (0.934542ms)
✖ GET /v1/legacy/audit answers each caller as expected (1.8605ms)
✔ a rejected profile change leaves the role as it was (0.821833ms)
✔ a token with non-ASCII bytes is refused, not a crash (0.71475ms)
✔ a body cut off mid-stream does not take the server down (153.7265ms)
ℹ tests 10
ℹ suites 0
ℹ pass 9
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 276.892458
✖ failing tests:
test at endpoints.test.mjs:45:3
✖ GET /v1/legacy/audit answers each caller as expected (1.8605ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected
{
admin: 200,
+ ana: 200,
- ana: 403,
anonymous: 401,
+ bo: 200
- bo: 403
}
...
Both members read the audit log with a 200. The old route checks that the caller is signed in, and nothing else. That is API5 in one line: the current route has the role check, and its older copy does not. In app.mjs, give the legacy route the same adminOnly wrapper as the current one:
{ method: 'GET', path: '/v1/legacy/audit', handler: adminOnly((req, res) => send(res, 200, auditLog)) },
Run once more:
$ node --test
✔ every route the server answers is in the inventory (3.757291ms)
✔ GET /v1/health answers each caller as expected (11.406042ms)
✔ GET /v1/reports/:id answers each caller as expected (2.639542ms)
✔ PATCH /v1/profile with role answers each caller as expected (4.151375ms)
✔ PATCH /v1/profile with displayName answers each caller as expected (1.256667ms)
✔ GET /v1/admin/audit answers each caller as expected (0.995875ms)
✔ GET /v1/legacy/audit answers each caller as expected (1.600125ms)
✔ a rejected profile change leaves the role as it was (0.929125ms)
✔ a token with non-ASCII bytes is refused, not a crash (0.60325ms)
✔ a body cut off mid-stream does not take the server down (153.365417ms)
ℹ tests 10
ℹ suites 0
ℹ pass 10
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 279.862875
Keep both fixes. The row stops the legacy route from regressing, and the inventory check fails the build the next time someone adds a route without writing its rules down. A better long-term fix is to retire the legacy route, which OWASP's API9 guidance asks for with a retirement plan per version.
Keep the inventory and the tests in step
The example reads the route table straight from the app. In a real service, get the same list from your framework's router, or from the OpenAPI file your build generates from the code. Then add three habits:
Run the matrix in CI on every pull request. A new route without a row fails the build, and the author has to state its rules.
Use real identities. The test signs in as an anonymous caller, two members and an admin. Two members are the minimum for object checks: one to own the data and one to be refused.
Diff the spec against the router. A route in one list and not the other is either undocumented or dead. Either way it needs a decision.
Broader REST controls, such as transport, secrets and headers, are in our REST API security guide. Lifecycle tasks are in the API security checklist. For where this fits in a full testing plan, see the API security testing guide.
Conclusion
API endpoint security is a list and a test. The list is every route the server answers, with who may call it and what it must refuse. The test calls each route as each kind of caller and asserts the exact refusal. The demo above found an old route that let members read the audit log. A documentation review would have missed it, because the route was never documented.
Frequently Asked Questions
What is API endpoint security?
It is the set of controls each API route enforces, and the tests that prove them: authentication, object, function and field authorization, input validation, rate limits and safe errors. It is decided per endpoint because each route touches different data and needs different rules.
How do I find endpoints that are not in the OpenAPI spec?
Compare the spec with the routes your router registers, the routes your gateway forwards and the paths in your access logs. A route in the router but not the spec is undocumented. The example above automates the router comparison in one test.
Is authentication enough to secure an endpoint?
No. Authentication tells you who is calling. The endpoint still has to decide whether that caller may use this function, read this object and change these fields. The legacy route in the demo required sign-in and still leaked the audit log to members.
Should an endpoint return 403 or 404 for another user's object?
Either is allowed. RFC 9110 lets a server answer 404 to hide that a forbidden resource exists. Pick one policy per resource, write it into your tests, and use it everywhere so responses do not leak which ids are real.
What is the difference between object and function level authorization?
Object level asks whether this caller may touch this specific record, such as report 1. Function level asks whether this caller may use this operation at all, such as an admin route. OWASP lists them as API1 and API5.
How do I test that an endpoint rejects a request?
Send the request a refused caller would send, and assert the exact status, not just "an error". Then check that nothing changed, as the demo's last test does for the role field. Include an allowed caller too, so a route that refuses everyone does not pass.
Does a 429 response need a Retry-After header?
RFC 6585 says a 429 response may include Retry-After, so it is optional. It also leaves how the server counts requests and identifies the user up to you.





