An API can require a valid access token and still expose another customer’s invoice. Authentication proves who sent the request. It does not prove that the caller may access the object named in /invoices/:id.
That distinction is the center of practical API security. The OWASP API Security Top 10 gives teams a shared risk vocabulary, but a Node.js application becomes safer only when those risks turn into route-level invariants and tests.
authorize every object lookup
Broken Object Level Authorization appears when a route fetches an object by user-controlled ID without checking ownership or permission:
// Unsafe: any signed-in user can choose any invoice ID.
app.get("/invoices/:id", requireSession, async (req, res) => {
const invoice = await db.invoice.findUnique({ where: { id: req.params.id } });
res.json(invoice);
});
Place the authorization condition in the query when possible:
app.get("/invoices/:id", requireSession, async (req, res) => {
const invoice = await db.invoice.findFirst({
where: { id: req.params.id, accountId: req.session.accountId },
select: { id: true, status: true, amount: true, createdAt: true },
});
if (!invoice) return res.status(404).json({ error: "not_found" });
res.json(invoice);
});
This reduces the chance that a later refactor fetches first and forgets the permission check. Test with two real accounts: create the object as account A, authenticate as B, and expect denial.
separate function permission from object ownership
An administrator route can have correct object checks and still let an ordinary user call an admin function. Centralize named permissions such as refund:create or user:suspend, then check object scope as a second step.
Do not rely on a hidden button or frontend route guard. Attackers call HTTP endpoints directly. Middleware can verify a session, but the route or service must enforce the permission needed for that specific action.
accept only declared properties
Mass assignment happens when request JSON is passed directly into an ORM update:
await db.user.update({
where: { id: req.session.userId },
data: req.body,
});
A caller may include role, accountId, or emailVerified even if the form never shows them. Parse a strict schema and construct the update from allowed fields:
const ProfileInput = z.object({
displayName: z.string().trim().min(1).max(80),
timezone: z.string().max(50),
}).strict();
const input = ProfileInput.parse(req.body);
await db.user.update({ where: { id: req.session.userId }, data: input });
Validation protects shape and bounds. Authorization still decides whether the caller may perform the change.
control resource consumption by cost
One request can be expensive without being frequent. An unbounded limit=100000, deeply nested GraphQL query, large upload, regex-heavy search, or endpoint that starts many background jobs can exhaust resources.
Apply limits at several boundaries:
- Maximum body and upload sizes before parsing.
- Pagination caps and database query timeouts.
- Per-route rate limits using account and source identities.
- Queue depth and concurrency limits for asynchronous work.
- Timeouts and response-size limits for outbound calls.
- Billing or quota checks for operations that consume paid providers.
A global “100 requests per minute” rule does not protect a route where one request launches 10,000 units of work.
treat outbound URLs as untrusted input
Server-Side Request Forgery occurs when the application fetches a user-selected URL. Blocking localhost as a string is not enough. DNS can resolve to private addresses, redirects can change destinations, and IPv6 has private and mapped forms.
Prefer allowlisted integrations. When arbitrary fetching is required, resolve and validate every destination, block private/link-local/metadata ranges, restrict schemes and ports, re-check redirects, set tight timeouts, and run the fetcher in a network environment that cannot reach internal control planes.
keep an API inventory
Old versions and forgotten debug routes cannot be protected by a policy nobody knows they exist. Generate an endpoint inventory from routing code or OpenAPI, record owner and version, and compare it with gateway traffic.
Remove deprecated routes on a stated date. Keep development endpoints out of production builds. Pin framework and middleware versions, and review dependency advisories as part of normal maintenance rather than only after an incident.
do not trust another API because it is popular
Data from a payment, identity, shipping, or AI provider is external input. Validate its schema, verify webhook signatures, bound response sizes, handle missing fields, and define timeout and retry behavior. A compromised or changed upstream service should not gain implicit permission to write arbitrary state.
For money-moving workflows, connect provider events to stable operation IDs and idempotent transitions. A valid provider message can still be duplicated or arrive out of order.
log decisions without leaking secrets
Security logs should make an event reconstructable: request ID, actor ID, route, object type and ID, authorization decision, status, reason code, latency, and deployment version. Do not log passwords, access tokens, full payment data, or entire request bodies by default.
Alert on patterns: repeated denied object access, login failures across many accounts, unusual administrative actions, signature failures, sudden outbound-request errors, and rate-limit changes. A log line nobody reviews is not detection.
turn the list into executable tests
For each sensitive route, write a small threat table:
| Question | Test |
|---|---|
| Can another account read this object? | Create as A, fetch as B |
| Can a normal user call the admin action? | Call without named permission |
| Can extra JSON fields change protected state? | Include role: admin |
| Can one request create extreme work? | Send maximum and over-maximum values |
| Can a duplicate repeat a side effect? | Deliver the same operation twice |
| Can errors reveal internals? | Trigger validation and dependency failures |
Run these tests in CI and repeat them after changes to authentication, tenancy, routing, or persistence. Automated scanners help with known shapes, but they cannot infer every business ownership rule.
The useful lesson from OWASP is not to memorize ten labels. It is to distrust every boundary: caller identity, object choice, function permission, input shape, resource cost, outbound destination, third-party data, and operational visibility. Make each boundary a rule the code and tests can prove.
Discussion
What would you try, change, or challenge after reading this guide? Specific results and errors help the next reader.
Comments will load as you reach this section.