
Denial of service is someone spending your CPU, memory, or bandwidth faster than you budgeted.
A bot on a cloud account can send billions of requests. Application-level expensive routes (search, export, password hash) need their own limits. A CDN absorbs the dumb flood. It does not fix an unbounded query.
The usual mistake is only tuning the load balancer and leaving /export unauthenticated and unbounded.
This page is which layer stops which flood, and the application budgets you still have to set.
On 17 October 2025 an Ask HN thread opened with a bot on AWS sending two billion requests a month. that thread. The useful half was not a recipe for flooding. It was how little of that traffic should ever reach bcrypt, a PDF renderer, or a 50 MB JSON parse. Node 18.0.0, shipped 19 April 2022, finally set server.requestTimeout to 300000 ms. Before that default, a public Node process with no reverse proxy had no request deadline at all.
Application-layer exhaustion is a handler that does expensive work for a stranger, a parser that will take a 50 MB body because someone raised the limit, or a socket that stays open because nobody set a deadline. This page is those four locks. Pair it with input validation for what the body may contain, with JWT on Express if the session is a bearer, and with the Ubuntu host guide for the process that binds the port.
The handler that runs bcryptHash or reportJob sits in the middle. A short timeout, a 32 kb body cap, and requireSession wrap it. A CDN 429 is the outer belt, not a replacement for those three.
SecureCoding
Timeouts before the handler runs
current Node.js http.Server page. headersTimeout defaults to 60000 ms. requestTimeout defaults to 300000 ms. Both arrived as non-zero defaults in Node 18.0.0 on 19 April 2022. If either expires, the server sends 408 and closes the socket without calling your listener. The same page says both must stay non-zero if you deploy without a reverse proxy. Set them yourself. Do not inherit five minutes on a login route.
const http = require("http");
const express = require("express");
const app = express();
const server = http.createServer(app);
server.headersTimeout = 10_000;
server.requestTimeout = 15_000;
server.keepAliveTimeout = 5_000;
// 0 is "no deadline". Do not set that on a public process.
server.listen(8080, "127.0.0.1");
nginx in front is the other deadline. client_header_timeout and client_body_timeout stop a client that dribbles bytes. send_timeout stops a client that reads the response at one byte a minute. proxy_read_timeout is how long nginx waits on your app. Keep it close to reportJob‘s queue ack, not to a twenty-minute PDF render on the request thread.
# /etc/nginx/conf.d/deadlines.conf
client_header_timeout 10s;
client_body_timeout 10s;
send_timeout 10s;
client_max_body_size 32k;
proxy_read_timeout 20s;
proxy_send_timeout 10s;
A worker that must run longer than twenty seconds does not run on the request. Enqueue reportJob and return 202. The timeout is then on the queue consumer, with a cancel, not on the socket a stranger still holds.
CVE-2023-44487, the HTTP/2 Rapid Reset flood disclosed on 10 October 2023, was closed in nginx, Caddy, and the big edges that year. I am naming the CVE as history, not as a lab. If your origin still speaks HTTP/1.1 on localhost behind that edge, the remaining death is a handler that waits, a parser that allocates, or a hash that runs for a stranger. Those three are this page. They are not a packet recipe.
Watch the open-socket gauge, not the request counter. A thousand connections that never finish headers will not show up as 200s. Alert when server.connections stays near the listen backlog while 2xx is quiet. That is the deadline failing, or the deadline never being set.
Body caps, then 413
express.json() and express.urlencoded() default to 100 kb. 100 kb is already too large for a login. 50 mb is a memory DoS you installed on purpose. Set the cap per mount, not once globally at a number that satisfies the fattest upload.
const jsonLimit = { limit: "32kb" };
const formLimit = { limit: "8kb", extended: false };
app.use("/login", express.urlencoded(formLimit));
app.use("/api", express.json(jsonLimit));
// Uploads stay on their own mount with a hard cap the disk can survive.
app.use("/upload", express.raw({ type: "application/octet-stream", limit: "2mb" }));
A 413 from the parser is the success case. Do not catch it and retry the parse. Do not raise jsonLimit because a report endpoint wanted a CSV in the body. Put the CSV on the upload mount or in object storage, then pass an id. Input validation is what the 32 kb may contain. This section is how large it may be.
nginx client_max_body_size 32k; must match or sit under the Express cap. If nginx allows 10 mb and Express allows 32 kb, you still buffer the extra megabytes in the proxy. If Express allows 10 mb and nginx allows 32 kb, the proxy is the lock. Align the two numbers and test both 413 paths.
Multipart is a separate cap. multer 2.2.0, published 15 June 2026, still defaults limits.fileSize to Infinity if you omit it. npm readme. Set fileSize and files on the instance. A JSON route must not also run multipart. Mount one parser per path. Two parsers on the same request is how a 32 kb JSON cap is skipped by a 200 mb form field.
const multer = require("multer");
const upload = multer({
storage: multer.diskStorage({ destination: "/var/app/incoming" }),
limits: { fileSize: 2 * 1024 * 1024, files: 1 },
});
app.post("/upload", upload.single("file"), (req, res) => {
res.status(201).json({ name: req.file.filename });
});
Mount /upload behind requireSession once that middleware exists. A stranger who can write 2 mb files without a session is still a disk problem.
| Lock | Default if you omit it | Set this |
|---|---|---|
| headersTimeout | 60000 ms since Node 18 | 10000 ms |
| requestTimeout | 300000 ms since Node 18 | 15000 ms |
| express.json limit | 100 kb | 32 kb on /api |
| multer fileSize | Infinity | 2 mb on /upload |
Authenticate before expensive work
The expensive work on a typical app is password hashing, report export, image transcode, and any call out to a model or a PDF library. A stranger who can hit those routes without a session is buying your CPU with a POST. The control is requireSession first, then the job. Login is the exception: there is no session yet, so loginLimiter is the lock, and bcryptHash still runs only after a cheap user lookup that failed closed.
const bcrypt = require("bcrypt");
const bcryptHash = 12;
function requireSession(req, res, next) {
if (!req.session || !req.session.userId) {
res.status(401).send("auth required");
return;
}
next();
}
async function startExport(req, res) {
const jobId = await reportJob.enqueue({
userId: req.session.userId,
kind: "csv",
});
res.status(202).json({ jobId });
}
// exportLimiter is constructed in the next section.
app.post("/reports/export", requireSession, exportLimiter, startExport);
app.post("/login", loginLimiter, express.urlencoded(formLimit), async (req, res) => {
const email = req.body.email;
if (typeof email !== "string" || typeof req.body.password !== "string") {
res.status(400).send("bad login");
return;
}
const user = await users.findByEmail(email);
if (!user) {
res.status(401).send("no");
return;
}
const ok = await bcrypt.compare(req.body.password, user.passwordHash);
if (!ok) {
res.status(401).send("no");
return;
}
req.session.userId = user.id;
res.status(204).end();
});
bcryptHash 12 is a policy number. Raise it only after you have loginLimiter. A higher cost without a quota is how a handful of POSTs pin a core. reportJob.enqueue is the named fallback for export: if you do not have a queue, the next code block is the in-process stand-in you must replace before production. Do not call a PDF library inside startExport.
// Named fallback only. Replace with a real queue before production.
const crypto = require("crypto");
const reportJob = {
async enqueue(payload) {
const jobId = crypto.randomUUID();
setImmediate(() => {
// worker process picks up jobId. not the request thread.
});
return jobId;
},
};
Put the quota on costly routes
express-rate-limit 8.6.2 published on 4 August 2026. Use that release. standardHeaders: "draft-8" is the current header shape. The in-memory store is per process. Two Node workers each have their own counter. That is fine for a single box. Share a store when you have more than one process, or accept that the quota is per worker.
const { rateLimit } = require("express-rate-limit");
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 10,
standardHeaders: "draft-8",
legacyHeaders: false,
skip: (req) => req.method === "GET",
});
const exportLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
limit: 5,
standardHeaders: "draft-8",
legacyHeaders: false,
keyGenerator: (req) => String(req.session.userId),
});
const apiLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 120,
standardHeaders: "draft-8",
legacyHeaders: false,
});
app.use("/api", apiLimiter);
Key by something you trust. Default is IP. Behind a proxy, set app.set("trust proxy", 1) only for the hop you own, then use req.ip. Do not key on X-Forwarded-For as a raw string a client can write. After login, exportLimiter keys on req.session.userId so one account cannot hide behind a pool of addresses.
A 429 is the success case. Send Retry-After if you can. Do not fail open when the store is down: 8.6.2 defaults passOnStoreError to false. Leave that default. A broken Redis is not a reason to hash passwords without a cap.
Do not set skipSuccessfulRequests on loginLimiter. A failed login is the expensive one. Counting only 200s lets a stranger spend bcrypt all afternoon. Skip 2xx on a read-heavy apiLimiter if you want. Never skip failures on a hash or an export.
IPv6 needs a subnet in 8.x. The package default ipv6Subnet is 56. Leave it unless you have measured that a single user arrives from a /64 pool you must treat as one key. Setting it to false keys the full address and a rotating privacy address walks around the cap.
The CDN is a belt
A CDN caches GET, absorbs a flood of junk hosts, and can return 429 at the edge. Put one in front of a public site. It is not a substitute for the four locks above. Cached HTML does not protect POST /login. An edge WAF that never sees your requireSession order cannot know that /reports/export is the expensive URL. You still set timeouts, body caps, auth, and exportLimiter on the origin.
Cache only what is public and idempotent. Send Cache-Control: private, no-store on session HTML. Origin shielding and a bot challenge are fine. They do not change the Node deadlines. The Ubuntu page is who binds 8080 to localhost behind that edge.
If the CDN offers an origin timeout, set it at or under proxy_read_timeout. An edge that waits 100 seconds while nginx waits 20 is how you pay for hung sockets twice. If the CDN offers a request-body cap, set it at or under client_max_body_size. Three layers with the same 32 kb number is boring and correct.
Do not write a “tarpit” that holds a client for an hour. That is a curiosity in the October 2025 thread, not a control for an origin you have to keep healthy. Close the socket. Return 429. Spend the CPU on reportJob for people who already authenticated.
Prove the 408, the 413, and the 429
You are not walking a flood against a host you do not own. You are proving your own origin returned the status the lock promised.
- A POST with a body larger than
jsonLimitreturns 413. - The eleventh
/loginfrom your own session in fifteen minutes returns 429. - A request that never finishes headers is closed. Use a short
headersTimeouton a staging process you started, then a client you own that sends a partial start line and waits. Expect 408 or a dropped socket. Do not point that client at production. - An unauthenticated POST to
/reports/exportreturns 401 before a job id exists.
# 413: body over jsonLimit
curl -sS -D - -o /dev/null -X POST "https://your-app.example/api/search" \
-H "Content-Type: application/json" \
--data "$(python3 -c 'print("{\"q\":\"" + "a"*40000 + "\"}")')"
# Expect: HTTP/2 413
# 429: eleventh login from you
# run your own loop against staging, ten times 204 or 401, eleventh 429
# 401: export without a session
curl -sS -D - -o /dev/null -X POST "https://your-app.example/reports/export"
# Expect: HTTP/2 401
Grep for the hatches this page named: a raised limit:, requestTimeout = 0, and any PDF or hash call that sits above requireSession.
rg -n "limit:\\s*['\\\"]?(50mb|100mb)|requestTimeout\\s*=\\s*0|bcrypt\\.(hash|compare)|reportJob" \
--glob '!node_modules'
Questions we keep getting
Does a CDN replace express-rate-limit?
No. The edge can cap a flood of GET. It does not know exportLimiter should be five per hour per userId. Keep both.
Is the Node 5 minute default enough?
It is better than no deadline. It is not a login policy. Set 10 to 15 seconds on the public process, and move long work to reportJob.
Should I raise express.json to 50mb for uploads?
No. Keep JSON small. Put files on /upload with express.raw and a cap the disk can survive, or send the client to object storage.



