A request body is the one part of an HTTP request the client controls completely: its size, its encoding, its structure and every value inside it. Frameworks hide most of the work (Express’s express.json(), Fastify’s built-in parser with JSON Schema), but interviewers still like the raw version, because it shows whether you know what those defaults protect you from. The question usually sounds like “Write a handler that accepts a JSON signup body safely,” and the parts they’re grading are the size limit, the order of checks and how you turn bad input into the right status code.
The example below uses only node:http on Node 22 so every step is visible.
Reading the body with a limit that can’t be bypassed
Here’s the reader. It checks the content type, rejects an oversized declared length early, and then counts bytes as they arrive:
class HttpError extends Error {
constructor(status, message) { super(message); this.status = status; }
}
const LIMIT = 16 * 1024; // 16 KiB is plenty for a signup form
async function readJson(req, limit = LIMIT) {
const type = req.headers['content-type'] ?? '';
if (!/^application\/json\b/i.test(type)) throw new HttpError(415, 'expected application/json');
const declared = Number(req.headers['content-length']);
if (declared > limit) throw new HttpError(413, `body over ${limit} bytes`);
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > limit) throw new HttpError(413, `body over ${limit} bytes`);
chunks.push(chunk);
}
try {
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
} catch {
throw new HttpError(400, 'malformed JSON');
}
}The Content-Length check is an optimisation, not the protection. A client can send Transfer-Encoding: chunked with no length at all and stream as much as it likes. If your only check is the header, a request without one goes straight into chunks until the process runs out of memory. The running size counter is the real limit, and it fires the moment the body crosses it, so you never hold more than one chunk over the limit.
Two smaller details are worth saying out loud in an interview. First, the chunks are joined as a Buffer and decoded once. Decoding each chunk to a string as it arrives (body += chunk) can split a multi-byte UTF-8 character across chunks and produce replacement characters. Second, the limit is counted in bytes, not characters. 'é'.length is 1 but it’s two bytes on the wire.
Pick the limit per route. 16 KiB is generous for a form; a document upload route needs a different reader entirely (streaming to disk or object storage, never buffered in memory).
Validating the parsed value
JSON.parse succeeding tells you only that the bytes were valid JSON. The result could be an array, a number, null, or an object where email is a list and age is the string "31". TypeScript types don’t help here: they describe what you hope arrives, and they vanish at runtime.
The validator below checks the top-level shape, rejects unknown fields, checks each type and range, and builds a fresh object from the fields it approved:
function validateSignup(body) {
const errors = [];
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return { errors: ['body must be a JSON object'] };
}
const allowed = new Set(['email', 'name', 'age']);
for (const key of Object.keys(body)) if (!allowed.has(key)) errors.push(`unknown field: ${key}`);
if (typeof body.email !== 'string' || body.email.length > 254 || !/^[^@\s]+@[^@\s]+$/.test(body.email)) errors.push('email: invalid');
if (typeof body.name !== 'string' || body.name.trim().length === 0 || body.name.length > 100) errors.push('name: 1-100 chars');
if (body.age !== undefined && !(Number.isInteger(body.age) && body.age >= 13 && body.age <= 130)) errors.push('age: integer 13-130');
if (errors.length) return { errors };
return { value: { email: body.email.toLowerCase(), name: body.name.trim(), age: body.age } };
}Returning a new object matters more than it looks. If the handler passes body itself to db.users.insert(body), any extra field the client adds (isAdmin: true, role: "owner", emailVerified: true) goes into the database. This is the mass assignment bug, and it’s common in code that validates the fields it knows about and forwards everything else. Rejecting unknown keys makes the mistake loud; building the output from approved fields makes it impossible.
Hand-writing validators gets tedious past a few fields. In real projects you’d use a schema library (Zod, Valibot, or Ajv with JSON Schema, which Fastify uses). Whatever you pick, check that it rejects or strips unknown keys by default, because not all of them do.
Mapping failures to status codes
The handler ties both together and converts errors into responses:
const server = http.createServer(async (req, res) => {
try {
const { errors, value } = validateSignup(await readJson(req));
if (errors) { res.writeHead(422, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ errors })); }
res.writeHead(201, { 'content-type': 'application/json' });
res.end(JSON.stringify(value));
} catch (err) {
const status = err.status ?? 500;
res.writeHead(status, { 'content-type': 'application/json', connection: 'close' });
res.end(JSON.stringify({ error: status === 500 ? 'internal error' : err.message }));
}
});A small test client sent nine requests at it. The real output:
valid 201 {"email":"ana@example.com","name":"Ana","age":31}
wrong content-type 415 {"error":"expected application/json"}
malformed 400 {"error":"malformed JSON"}
wrong types 422 {"errors":["email: invalid","name: 1-100 chars","age: integer 13-130"]}
extra field 422 {"errors":["unknown field: isAdmin"]}
proto key 422 {"errors":["unknown field: __proto__"]}
array body 422 {"errors":["body must be a JSON object"]}
too big (declared) 413 {"error":"body over 16384 bytes"}
too big (chunked) 413 {"error":"body over 16384 bytes"}The last line is the one to notice. That request was a streamed ReadableStream body sent with duplex: 'half', so it had no Content-Length, and only the running counter caught it.
Each status code tells the client something different. 415 means “I don’t speak this format”, 413 “too large”, 400 “I couldn’t parse it”, and 422 “I parsed it, and the values are wrong”. Collecting all field errors at once (the “wrong types” line) saves a form three round trips. Keep the error messages about the input; never echo stack traces or internal details, which is why a 500 says only “internal error”.
The connection: close header on errors is deliberate. When the reader throws partway through a body, the rest of that body is still on the socket. Closing the connection after the response stops Node from trying to parse leftover body bytes as the next request.
The proto key
The “proto key” request sent {"__proto__": {"isAdmin": true}}. JSON.parse doesn’t treat that as a prototype; it creates an ordinary own property named __proto__, which is why Object.keys found it and the allow-list rejected it. The danger comes later, when code copies that object with an assignment-based merge:
const body = JSON.parse(`{"name":"A","__proto__":{"isAdmin":true}}`);
const viaAssign = Object.assign({}, body);
const viaSpread = { ...body };
console.log("assign isAdmin:", viaAssign.isAdmin, "| spread isAdmin:", viaSpread.isAdmin);assign isAdmin: true | spread isAdmin: undefinedObject.assign writes with ordinary assignment, so target.__proto__ = {...} triggers the prototype setter and the result inherits isAdmin. Spread defines properties directly and doesn’t. Recursive “deep merge” helpers that use assignment can go further and modify Object.prototype for the whole process. Allow-listing keys and constructing a new object avoids the whole category.
Limits outside the handler
The body limit is one of several. Node’s HTTP server caps header size (16 KiB by default, adjustable with --max-http-header-size), and server.requestTimeout (300 seconds by default) bounds how long a client can take to send the whole request. A client that trickles one byte per second stays under any size limit for a long time, so the time limit matters as much. If you sit behind a reverse proxy, it usually enforces its own body limit too (nginx’s client_max_body_size defaults to 1 MB), and the two should agree so the error a client sees is predictable.
Interview practice
A teammate’s endpoint does this, and says it’s safe because the TypeScript type is { email: string; name: string }:
const body = JSON.parse(await getRawBody(req));
await db.users.update(userId, body);getRawBody reads the whole stream with no limit. List the problems and how you’d fix them.
Answer
There are three separate problems. First, no size limit: a client can stream an arbitrarily large body, and since it’s buffered in memory, a handful of requests can exhaust the heap. Fix: count bytes while reading and stop at a per-route limit with 413, not just a Content-Length check.
Second, no runtime validation. The TypeScript type is erased, so email could be an array or name an object, and those values reach the database layer, which might coerce them, throw a confusing error, or store garbage. Fix: validate types, lengths and formats at runtime, and return 422 with field errors.
Third, mass assignment. Passing the parsed object straight into update lets the client set any column the ORM maps (role, isAdmin, passwordHash), and with an assignment-based merge somewhere in the stack a __proto__ key can do worse. Fix: allow-list fields and build a new object containing only approved, normalised values.
I’d also check the content type (415) and catch the JSON.parse error (400) so a typo from the client doesn’t become a 500.