Skip to content
Back to field notes
FrameworksJune 6, 20266 min read

Express.js API security hardening before an AI-built API goes public

An AI-scaffolded Express API usually ships with no helmet, no input validation, and no rate limiting, and this note helps you decide what to add before the API URL is public.

ExpressNodeAPI hardening

A generated Express API ships with the defaults missing

The concrete risk is what is absent, not what is present. A typical AI-generated Express server gives you app.use(express.json()), a handful of route handlers, and app.listen(). That runs, returns JSON, and looks done. What it usually omits is the layer that a public API needs the moment its URL leaves your machine: security response headers, input validation on the request body and query, and a rate limit on the endpoints anyone can call.

Two of those gaps are observable from outside before anyone touches your code. Missing security headers show up in any response. An unauthenticated endpoint that answers anonymous requests shows up by calling it. The third, missing input validation, is mostly internal: you can probe a malformed request and watch the response, but whether a handler safely rejects bad input is something you confirm by reading the handler, not by guessing from the outside.

This note is the minimum hardening pass for an Express API, not a full Node security course. The three controls below are the ones AI scaffolding skips most reliably, and the ones that matter most before the API is reachable from the open internet.

Why AI-built Express apps skip them

These controls are opt-in. Express ships intentionally minimal, so headers, validation, and rate limiting are all middleware you add yourself. Nothing in a generated server forces them in, and a prompt like "build me a REST API for tasks" is satisfied by routes that read and write data. The result works on the first request, which is exactly what makes the gap invisible from the inside.

The skipped step is the review loop where someone asks who can call this endpoint, what happens when the body is garbage, and what stops a script from calling it ten thousand times a minute. That review is the part fast scaffolding and fast deploy compress away.

  • helmet is a separate package and a separate app.use line; if the prompt did not ask for it, it is not there, and the default Express header set is what ships.
  • Input validation means wiring a schema library and rejecting bad payloads, which the model only adds when prompted; otherwise handlers trust req.body and req.query as given.
  • Rate limiting is another middleware most generated apps never install, so every public route, including login, accepts unlimited requests by default.

Check your own API from the outside

You can confirm the observable gaps on your own running API without attacking anything. Point these at your own deploy.

Headers: send a request and read the response headers. In the browser DevTools Network tab, click any request to your API and look at the Response Headers panel, or run curl -I against an endpoint. helmet sets several headers; the easiest tell is whether headers like X-Content-Type-Options: nosniff and a Content-Security-Policy appear, and whether X-Powered-By: Express is still being sent. The suspicious result is the default set with X-Powered-By present and the helmet headers absent. The safer result is X-Powered-By gone and the security headers present.

Open endpoints: list your routes and call the ones that return or change data without sending any auth token. A GET that returns real records to an anonymous caller, or a POST that writes one, is the suspicious result. The safer result is a 401 or 403 on anything that should require a logged-in user. Note the distinction: missing the token check is an authentication gap; returning another user's records to a caller who is logged in but not authorized is a separate authorization gap that this outside probe does not cover.

Input handling and rate limits: send one request with a deliberately malformed body to your own endpoint and watch the response. A clean 400 with a validation message is the safer result; a 500 stack trace or a silently accepted bad record is suspicious. For rate limiting, repeated rapid requests that all return 200 with no 429 anywhere suggest no limiter is in place. Treat the malformed-input probe as a hint, not proof; whether the handler is actually safe is something you confirm by reading it.

Add helmet, validation, and a rate limit

The minimum hardening pass is three middleware additions, and each has a clear edge it does not cover.

  • helmet: add app.use(helmet()) early in the middleware chain. It sets a sane default set of security response headers and removes X-Powered-By. What it does not do is authenticate anyone or validate input. Headers reduce some browser-side classes of risk; they do not protect a route that should have required a login. See the deeper note on security headers for AI-built apps.
  • Input validation: validate req.body, req.params, and req.query against a schema with a library such as zod or a Joi/celebrate setup, and reject anything that does not match with a 400 before the handler runs. This stops malformed and unexpected fields from reaching your logic. It does not decide who is allowed to make the call; a perfectly valid payload from an unauthorized caller is still a separate problem.
  • Rate limiting: add a limiter such as express-rate-limit, and apply a stricter limit on sensitive routes like login and password reset. This blunts brute force and crude abuse. Behind a proxy or load balancer you must set app.set('trust proxy', ...) correctly, or the limiter keys on the wrong IP. A rate limit slows guessing; it is not a substitute for the auth check itself.

These three controls harden the request edge. They do not replace authentication and authorization on each route, and they do not review your business logic. A validated, rate-limited request from an attacker who should never have reached the endpoint is still a problem you fix with auth, not with these middlewares.

Where VibeCodeGuard fits, and what it does not prove

VibeCodeGuard's Launch Check scans the public surface of your API, so the two outside-observable gaps are directly in scope. It reads your response headers and flags a missing helmet configuration, since the absence of those headers is a direct header-scan finding, including a lingering X-Powered-By. It also probes public endpoints and flags ones that answer anonymous requests when they look like they should not. Each finding comes with severity and fix direction.

What it does not do is read your route handlers. It cannot confirm that your input validation is correct, that your authorization logic returns the right records to the right user, or that your rate limit is tuned well. Those need a code review, which the scan does not replace. It is not a penetration test and it does not make the API secure on its own.

Next step: add helmet, a request-body validator, and a rate limiter to your Express app, then re-check the headers and your public endpoints. Run a focused Launch Check against the public URL before you share it, so a missing-header or open-endpoint finding shows up in your scan and not in someone else's traffic.

A clean Launch Check on headers and open endpoints does not prove the API is hardened. The scan sees what an outside caller can reach; it does not read your validation schemas or your auth checks, and a missing rate limit on a logic-level route may not surface. Treat a flagged header or open endpoint as a launch blocker, and a clean result as one signal among several.

> launch check

Scan the public surface before launch.

Get severity, evidence, and practical fix guidance for the checks VibeCodeGuard can run from the outside.