Short answer: Secure the request lifecycle end to end: bound and parse input, authenticate the caller, authorize the action and object, use safe data APIs, constrain outbound requests, and expose only deliberate errors and logs.
1. Bound the request before parsing it
Set limits for headers, body size, JSON depth, upload size and request time at the reverse proxy and application. Reject unexpected content types and duplicate or ambiguous parameters. Schema validation should reject unknown fields for security-sensitive operations so an accidental mass-assignment path cannot grow silently.
2. Centralize identity, distribute authorization
Authenticate once in well-tested middleware, but authorize at every operation. A token with a valid signature may still be expired, issued for another audience or lack the required permission. Do not trust a tenant or user identifier submitted in the body when the session already defines it.
app.get("/api/invoices/:id", requireSession, async (req, res) => {
const invoice = await db.query(
"SELECT id,total FROM invoices WHERE id = $1 AND workspace_id = $2",
[req.params.id, req.session.workspaceId]
);
if (!invoice.rowCount) return res.sendStatus(404);
res.json(invoice.rows[0]);
});3. Protect interpreters, files and outbound requests
Use parameterized database queries; never construct commands from request strings. Generate upload names, store outside the web root and verify how downstream processors interpret the file. For URL fetchers, allow required schemes and hosts, re-check resolved addresses, restrict redirects and enforce network egress rules that block internal destinations.
4. Fail and operate safely
Return stable error envelopes without stack traces or database details. Log the request correlation ID, security event and actor—not tokens or sensitive bodies. Apply rate limits by a meaningful identity and protect login, recovery and expensive endpoints separately. Run supported Node.js and dependency versions, lock dependencies, and review lifecycle scripts before installation.
The Node.js Permission Model can reduce accidental access to files, network or child processes, but its documentation explicitly does not position it as a sandbox for malicious code. Use it as defense in depth, not the isolation boundary for untrusted packages.
Common mistakes
- Trusting decoded JWT claims without verifying algorithm, issuer and audience.
- Authorizing a route but not the specific database row.
- Letting Express defaults define production body and timeout limits.
- Passing a validated URL to a client that follows redirects to private networks.
- Returning raw exceptions or logging authorization headers.
- Using the Permission Model as a substitute for process or container isolation.
Checklist
- Proxy and application enforce explicit size and time limits.
- Schemas reject invalid types, ranges and unexpected sensitive fields.
- Tokens validate signature, issuer, audience, expiry and intended use.
- Each object query is scoped to the authenticated principal or tenant.
- SQL is parameterized; commands and template evaluation avoid user strings.
- Outbound requests have application and network restrictions.
- Errors and logs exclude secrets and implementation details.
- Runtime and dependencies follow a supported update policy.
Limits
API code review does not cover a reverse proxy, cloud IAM, database permissions or production egress unless those configurations are included. Load behavior and race conditions also require runtime tests. Threat-model the deployed system, not only the Express or Fastify code.
Primary sources
Related resources
Review a Node.js API
Apply these controls to the actual code and product boundaries.