When we review Node.js APIs, the most common findings are not sophisticated. A route that forgot to check ownership. A JSON body parser with no size limit. An error handler that returns stack traces. Validation on the endpoints someone thought about and not on the rest. Each is easy to fix once found, and each is the kind of mistake that repeats across a codebase.

The fix that scales is to make the secure behaviour the default, so a developer adding a route gets it without thinking. This post is the template we give teams starting a new Node.js API, using Express as the example; the same ideas apply to Fastify, NestJS and others.

1. Bound every input

import express from 'express';

const app = express();
app.disable('x-powered-by');
app.use(express.json({ limit: '100kb', strict: true }));
app.use(express.urlencoded({ extended: false, limit: '20kb' }));
  • Body size limits stop memory exhaustion from oversized payloads. Endpoints that need larger bodies, such as uploads, get their own route-level parser and limit.
  • strict: true accepts only objects and arrays at the top level.
  • extended: false avoids the deeply nested object parsing of the qs library for form bodies.

Also set server timeouts, so slow clients cannot hold connections forever:

const server = app.listen(3000);
server.headersTimeout = 20_000;
server.requestTimeout = 30_000;

2. Validate with schemas, everywhere

Every route declares a schema for params, query and body, and a shared middleware enforces it. We use Zod; JSON Schema with Ajv works equally well.

import { z } from 'zod';

const validate = (schema) => (req, res, next) => {
  const result = schema.safeParse({ params: req.params, query: req.query, body: req.body });
  if (!result.success) {
    return res.status(400).json({ error: 'invalid_request', issues: result.error.issues.map(i => i.path.join('.')) });
  }
  req.valid = result.data;
  next();
};

const createOrder = z.object({
  params: z.object({}),
  query: z.object({}),
  body: z.object({
    items: z.array(z.object({ sku: z.string().max(40), qty: z.number().int().min(1).max(100) })).min(1).max(50),
    note: z.string().max(500).optional(),
  }).strict(),
});

apiRoutes.post('/orders', validate(createOrder), handlers.createOrder);
  • .strict() rejects unknown fields, which prevents mass assignment, where a client sends isAdmin: true and the handler spreads the body into a database update.
  • Handlers use req.valid, never req.body directly.
  • A lint rule flags routes registered without validate(...).

3. Authentication, then authorization, in that order, on the server

Authentication runs globally after a small, explicit set of public routes, so any route added later is protected by default:

const publicRoutes = express.Router();
publicRoutes.get('/health', handlers.health);
publicRoutes.post('/auth/login', validate(loginSchema), handlers.login);
publicRoutes.post('/auth/password-reset', validate(resetSchema), handlers.passwordReset);

app.use(publicRoutes);
app.use(authenticate);          // everything registered below requires a valid session or token
app.use('/api', apiRoutes);

Making a route public now requires adding it to publicRoutes, which stands out in review.

Authorization depends on the resource, so it happens in a policy layer that every handler calls, and queries are scoped to the caller:

async function getInvoice(req, res) {
  const invoice = await db.invoice.findFirst({
    where: { id: req.valid.params.id, accountId: req.user.accountId },   // scoped, not fetched then checked
  });
  if (!invoice) return res.status(404).end();
  res.json(serializeInvoice(invoice));
}

Returning 404 for objects the user cannot access avoids confirming that they exist. Scoping in the query means forgetting a separate check cannot leak another account's data.

4. Output only what you mean to

Never return database rows directly. Serializers pick fields explicitly:

const serializeInvoice = (i) => ({ id: i.id, number: i.number, total: i.total, status: i.status, issuedAt: i.issuedAt });

New columns, such as internal notes or payment tokens, then cannot appear in API responses by accident.

5. Errors that help clients and not attackers

app.use((err, req, res, next) => {
  const id = crypto.randomUUID();
  req.log.error({ err, errorId: id }, 'unhandled error');
  if (res.headersSent) return next(err);
  res.status(err.status && err.status < 500 ? err.status : 500).json({
    error: err.status && err.status < 500 ? err.code ?? 'bad_request' : 'internal_error',
    errorId: id,
  });
});

Stack traces, SQL errors and library messages go to logs with an error id; the client gets a stable code and the id for support. Run with NODE_ENV=production, which also disables Express's development error pages.

6. Security headers and CORS

import helmet from 'helmet';
import cors from 'cors';

app.use(helmet());                         // sensible security headers for an API
app.use(cors({
  origin: ['https://app.example.com'],     // exact origins, never reflect the request origin
  credentials: true,
}));

For a pure JSON API, most browser-oriented headers matter less, but they cost nothing and protect any HTML the API might serve, such as error pages or documentation.

7. Rate limits

Per-IP limits for anonymous endpoints, per-account limits for authenticated ones, stricter limits for login, password reset and expensive operations. Store counters in Redis so limits apply across instances, and trust X-Forwarded-For only from your own proxies:

app.set('trust proxy', ['loopback', '10.0.0.0/8']);   // only our load balancers

Without that setting, either every request appears to come from the load balancer, or, if trust is too broad, clients can spoof their address and bypass limits.

8. Secrets and configuration

  • Load configuration from the environment at startup, validate it with a schema, and fail fast if anything is missing.
  • Never log configuration objects; redact authorization, cookie, password and token fields in the logger.
  • Secrets come from a secrets manager or the platform, never from files committed to the repository.

9. Dependencies

  • Lock files committed; CI installs with npm ci.
  • Dependency updates through automated pull requests, with tests.
  • npm audit or an equivalent scanner in CI, with a policy on which severities block merges.
  • Review new dependencies: maintenance, popularity, install scripts. Fewer dependencies is a security feature.

10. Tests for the defaults

The template includes tests that fail if a default is removed:

test('rejects bodies over the limit', async () => {
  const res = await request(app).post('/orders').set(auth).send({ note: 'x'.repeat(200_000) });
  expect(res.status).toBe(413);
});

test('unknown fields are rejected', async () => {
  const res = await request(app).post('/orders').set(auth).send({ items: [{ sku: 'a', qty: 1 }], isAdmin: true });
  expect(res.status).toBe(400);
});

test('other accounts cannot read an invoice', async () => {
  const res = await request(app).get(`/invoices/${invoiceOfAccountB}`).set(authAccountA);
  expect(res.status).toBe(404);
});

Rolling the template out

New services start from the template repository. For existing services, we ran a one-time review against the same ten items and opened tickets for gaps, which most teams closed within a sprint because each fix was small.

Key takeaways

  • Most API vulnerabilities are missing defaults; make secure behaviour the default in the template.
  • Bound inputs, validate every route with strict schemas, and use only validated data.
  • Authenticate globally with an explicit public allow-list; authorize in scoped queries.
  • Serialize outputs explicitly and keep error details in logs, not responses.
  • Configure headers, CORS, rate limits and trusted proxies deliberately, and test the defaults.

References