TX·01 · NODE.JS · LOGGED 2026·05 · 12 MIN
Hardening a Node/Express API: a working checklist, not a listicle
Every Express security post on the internet tells you to install Helmet. Very few tell you what it actually changes, what it can't do, or what to check after. This is the list I run on my own services before they face the public internet, with the code to go with each step.
Know what you're defending
A hardening checklist only makes sense against a threat model, so here's the one this article assumes: a small Express API on the public internet, sitting behind one reverse proxy (nginx, Caddy, or a platform load balancer), talking JSON to a browser or a mobile client.
At this size, the attackers that matter aren't nation states. They're scanners, credential stuffers, scrapers, and bots throwing ten-year-old exploits at every IPv4 address with port 443 open. I watch them hit my own boxes every day. They are boring, automated, and constant, and the good news is that boring attacks fall to boring defenses applied consistently. That's what a checklist is for.
Headers: what Helmet does and doesn't
Helmet is a bundle of middleware functions that set security headers on every response. Installing it is one line. Knowing what you just turned on is the part most posts skip, so here's the setup I use, with the defaults tightened for a JSON API:
const express = require("express");
const helmet = require("helmet");
const app = express();
// exactly one reverse proxy in front of us; needed for real client IPs
app.set("trust proxy", 1);
app.disable("x-powered-by");
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
frameAncestors: ["'none'"],
},
},
})
);
// a JSON API has no business accepting 100mb bodies
app.use(express.json({ limit: "10kb" }));
The highlights of what that buys you: X-Content-Type-Options: nosniff
stops browsers from guessing content types, frame-ancestors 'none'
kills clickjacking, and Strict-Transport-Security tells browsers to refuse
plain HTTP on future visits (it only means something once you're serving over
TLS, which your proxy should already handle). Dropping the
X-Powered-By header doesn't stop a determined attacker from
fingerprinting Express, but there's no reason to volunteer it either.
Two things Helmet will never do for you: validate input and control who can call you. It sets headers. Everything below this line is still your job.
Rate limiting without punishing real users
One global limit is the usual advice and it's wrong. Different routes have different abuse profiles: nobody legitimately attempts login 40 times in a minute, but a dashboard polling an API absolutely makes 300 requests in fifteen. So give the expensive and abusable routes their own budgets:
const rateLimit = require("express-rate-limit");
// generous: normal clients never see this one
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 300,
standardHeaders: "draft-7",
legacyHeaders: false,
});
// tight: failed logins burn the budget, successful ones don't
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 10,
skipSuccessfulRequests: true,
});
app.use("/api/", apiLimiter);
app.use("/api/login", authLimiter);
skipSuccessfulRequests on the auth limiter is the detail that
keeps real users happy: someone who logs in and out a few times pays nothing,
while a credential stuffer eats through ten failures and hits a wall. One
caveat worth knowing: the default store is in-memory, which resets on restart
and doesn't share state across instances. The moment you scale past one
process, back it with Redis.
Validate input at the door
My rule is that route handlers should never see a request body that hasn't already been through a schema. Whitelist what you accept, reject everything else, and do it in one reusable middleware so there's no route where you forgot. I use Zod because the schemas double as documentation:
const { z } = require("zod");
const CreateUser = z.object({
email: z.string().email().max(254),
handle: z.string().regex(/^[a-z0-9_]{3,20}$/),
});
function validate(schema) {
return (req, res, next) => {
const parsed = schema.safeParse(req.body);
if (!parsed.success) {
// deliberately vague: don't teach the attacker your schema
return res.status(400).json({ error: "invalid input" });
}
req.body = parsed.data; // replace, never merge
next();
};
}
app.post("/api/users", validate(CreateUser), createUser);
Three habits hiding in that snippet. Replacing req.body with
parsed.data means unknown keys get dropped, which shuts down a
whole family of mass-assignment bugs where an attacker adds
"isAdmin": true to a signup payload. Capping string lengths
keeps someone from storing a 2 MB "email address" in your database. And
the error response never echoes the input back, because reflected input in
error messages has a long history of turning into injection.
Errors that keep their mouth shut
Out of the box, a thrown error in Express development mode responds with a
full stack trace: file paths, line numbers, sometimes chunks of your query.
That's a free reconnaissance report. Setting NODE_ENV=production
is the first fix. Writing your own terminal error handler is the real one:
// 404s: same shape as every other response, no route echo
app.use((req, res) => {
res.status(404).json({ error: "not found" });
});
// last middleware in the chain, the four-arg signature matters
app.use((err, req, res, _next) => {
console.error(err); // full detail stays in your logs
const status = err.status && err.status < 500 ? err.status : 500;
res.status(status).json({
error: status === 500 ? "internal error" : err.message,
});
});
The rule it encodes: client mistakes (4xx) can carry a message, server failures (5xx) say "internal error" and nothing more. The stack trace goes to your logs, where it belongs. Apply the same thinking to auth flows: "user not found" and "wrong password" should be the same response, or you've built a free username oracle.
Dependencies, the biggest attack surface
The code you wrote is a rounding error next to what actually ships. A fresh
Express project pulls in dozens of packages before you've written a route,
and every one of them is code you're vouching for in production. Recent
supply-chain attacks haven't bothered exploiting servers at runtime; they
compromise a maintainer account and put malware in a
postinstall script, which runs on your machine the moment you
install. So:
# deploys and CI: exactly the lockfile, no lifecycle scripts
npm ci --ignore-scripts
# what's actually exploitable in prod (dev deps don't ship)
npm audit --omit=dev
# know what you're shipping before you ship it
npm ls --omit=dev --depth=0
Beyond the commands, the habit that matters most is refusing dependencies
you don't need. Before adding a package, I check whether Node already does
it (it now has a native fetch, a test runner, and
node:crypto covers most needs), how many dependencies the
package itself drags in, and when it was last touched. Every "no" is attack
surface that never exists.
Secrets and config
The rules are old and still ignored daily: secrets live in environment
variables, .env exists only on your dev machine and is in
.gitignore before the first commit, and production gets its
config from the platform's secret store. The part almost nobody does is
validating config at boot:
const REQUIRED = ["DATABASE_URL", "SESSION_SECRET", "NODE_ENV"];
const missing = REQUIRED.filter((key) => !process.env[key]);
if (missing.length) {
console.error("missing env vars:", missing.join(", "));
process.exit(1);
}
Five lines, and the failure mode changes completely. Without it, a missing
SESSION_SECRET means your app limps into production signing
sessions with undefined, and you find out from a security
report. With it, the deploy fails loudly in thirty seconds. Crashing at
boot beats running wrong every single time.
Logging you'll actually read
Hardening without logging is locking the doors and never checking the windows. You want structured logs (JSON, greppable, parseable) with the sensitive fields scrubbed before they're ever written. Pino does both and stays out of the request path's way:
const pino = require("pino");
const pinoHttp = require("pino-http");
const logger = pino({ level: process.env.LOG_LEVEL || "info" });
app.use(
pinoHttp({
logger,
// never let credentials hit the log file
redact: ["req.headers.authorization", "req.headers.cookie"],
})
);
What's worth logging: authentication events (success and failure), rate limiter hits, validation rejections, and anything returning 5xx. What's never worth logging: passwords, tokens, session cookies, or full request bodies. A log file with credentials in it is just a second database to breach, minus the encryption.
The checklist
Everything above, compressed into the list I actually run before a service goes public:
- Helmet installed, CSP directives reviewed, not just defaults
trust proxymatches the real proxy count- JSON body limit set (10kb unless there's a reason)
- Separate rate limits on auth and expensive routes
- Every route body passes through a schema; unknown keys dropped
- Terminal error handler; 5xx responses say nothing specific
NODE_ENV=productionconfirmed on the host- Deploys use
npm ci --ignore-scriptsagainst the lockfile npm audit --omit=devclean or consciously waived- Required env vars validated at boot, app exits if missing
- Structured logging with auth headers and cookies redacted
Nothing here is exotic, and that's the point. Everything above was tested on Node 24 LTS with Express 5 and current Helmet and express-rate-limit releases, running on my own lab boxes. Print the list, run the list, and spend the time you saved on the parts of your app that are actually novel.