Body Parsing
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Express 5.3.0 (released 2026-10-09; the latest Express 4 is 4.22.3), Node.js 18 or newer,
which is the minimum Express 5 declares, and body-parser 2.4.0, the package that Express 5.3 depends on and that
implements the four built-in parsers. Everything was checked on 2026-10-29. A body parser is the middleware that reads
the bytes of an HTTP request body, decodes them according to the Content-Type and Content-Encoding headers and
stores the result in req.body. Express never reads a body by itself, so every route that needs one depends on a parser
being mounted before it. The snippets are ESM, use node: prefixes and belong to the Bookshelf scenario used across
this section. Middleware ordering in general is covered in
Middleware Fundamentals, what req offers in
Request, and the validation that must follow parsing in
Validation and Input Handling.
The four built-in parsers
Express exposes four factories on the express function. Each returns a middleware, looks only at requests whose
Content-Type matches its type option, and leaves every other request untouched for a later middleware.
| Factory | Parses | req.body becomes |
Default type |
|---|---|---|---|
|
JSON text, any Unicode encoding |
the value returned by |
|
|
HTML form bodies ( |
an object of keys and values; values are strings or arrays when |
|
|
any bytes |
a |
|
|
text |
a string |
|
Express 4.16 introduced express.json and express.urlencoded, and 4.17 added express.raw and express.text, all as
thin re-exports of the separate body-parser package, and Express 5 keeps them as re-exports of body-parser 2. They are the same code: express.json and bodyParser.json from the standalone
package behave identically, so a project no longer needs to install body-parser just to parse JSON or forms.
Multipart bodies (multipart/form-data, used for file uploads) are deliberately not handled by any of them; use a
multipart parser such as multer, described in File Uploads.
Source: Express API: express() and Express resources: body-parser
req.body is undefined without a parser
In Express, req.body does not exist until a parser sets it. A route that reads req.body.title on an application with
no parser throws a TypeError, because req.body is undefined. This is by far the most common first bug with Express,
and Express 5 widened it: the parsers themselves now also leave req.body as undefined whenever they did not parse
anything. In the 4.x line body-parser 1 initialised req.body to an empty object {} for every request that went
through it, so code such as req.body.title returned undefined instead of throwing. In body-parser 2 (Express 5) the
property is left undefined when the request has neither a Content-Length nor a Transfer-Encoding header, when the
Content-Type does not match the parser’s type, or when the body is empty.
There are three consequences. First, mount the parser before the routes that need it and keep the matching
Content-Type in mind: a curl call that forgets the JSON content-type header sends the body but leaves
req.body undefined. Second, never assume the shape: read fields with optional chaining or validate the body first.
Third, treat everything in req.body as untrusted user input. req.body.title may be missing, may be an array, an
object or a number, and req.body.title.trim() can fail in several ways depending on what the client sent.
import express from 'express';
export function createApp() {
const app = express();
// Parsers must be mounted before the routes that read req.body.
app.use(express.json());
app.use(express.urlencoded());
app.post('/api/v1/books', (req, res) => {
// In Express 5 req.body is undefined when nothing was parsed, so guard it.
const { title, author } = req.body ?? {};
if (typeof title !== 'string' || typeof author !== 'string') {
res.status(422).json({ error: 'title and author must be strings' });
return;
}
res.status(201).json({ id: 1, title, author });
});
return app;
}
Source: Express API: req.body
express.json
express.json([options]) parses JSON bodies. It only runs for requests whose Content-Type matches type, accepts any
Unicode encoding declared by the charset parameter, and sets req.body to the parsed value. A syntactically invalid
body produces an error with status 400 that is passed to next(err). The options are:
| Option | Default | Meaning |
|---|---|---|
|
|
Maximum body size. A number is a count of bytes; a string such as |
|
|
When |
|
none |
Passed as the second argument of |
|
|
Which media types to parse: an extension name ( |
|
none |
|
|
|
When |
|
|
Charset assumed when the |
The strict option matters for security hygiene rather than convenience: with strict on, a top-level string, number or
null never reaches your handler, so req.body is always something you can destructure. Leave it on unless a client
really sends scalar JSON documents.
import express from 'express';
export function mountJsonParsers(app) {
// Public API: small bodies, strict top level, dates revived from ISO strings.
app.use(
'/api/v1',
express.json({
limit: '50kb',
strict: true,
reviver(key, value) {
if (key === 'publishedAt' && typeof value === 'string') {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date;
}
return value;
},
// Also accept JSON:API and problem+json media types besides application/json.
type: ['application/json', 'application/vnd.api+json', 'application/*+json'],
}),
);
// Import endpoint: a larger limit for this one path only.
app.use('/api/v1/books/import', express.json({ limit: '2mb' }));
}
Source: Express API: express.json
The type function form is the escape hatch for decisions based on anything in the request, such as only parsing when a
client explicitly sends a body:
import express from 'express';
// Parse JSON for any content type that ends in +json, but only for write methods.
export const jsonForWrites = express.json({
type: (req) => req.method !== 'GET' && req.headers['content-type']?.includes('json') === true,
});
Source: Express API: express.json
express.urlencoded
express.urlencoded([options]) parses application/x-www-form-urlencoded bodies, the format a plain HTML <form>
posts. It shares limit, type, verify, inflate and defaultCharset with express.json (with the defaults shown
above; defaultCharset here must be utf-8 or iso-8859-1) and adds the following options:
| Option | Default | Meaning |
|---|---|---|
|
|
|
|
|
Maximum number of parameters. A request with more is rejected with 413. |
|
|
With |
|
|
When |
|
|
When |
The most visible Express 5 change is the default of extended. Express 4 warned that extended had no default and
required you to pick one; many tutorials wrote express.urlencoded({ extended: true }) by habit. In Express 5 the
default is false, so a nested field such as address[city]=Madrid arrives as a literal key address[city] instead of
a nested object. Choose extended: true only when you really need nesting, because the richer parser costs more and
exposes the application to deeper structures.
import express from 'express';
// Flat forms (login, search): the default parser is enough.
export const loginForm = express.urlencoded({ limit: '10kb', parameterLimit: 20 });
// Admin forms with nested fields such as author[name] and tags[]: opt in to qs, with tight bounds.
export const adminForm = express.urlencoded({
extended: true,
limit: '100kb',
parameterLimit: 200,
depth: 3,
});
Source: Express API: express.urlencoded
express.raw and express.text
express.raw([options]) reads the body into a Buffer and express.text([options]) decodes it into a string. Both
accept limit, type, verify and inflate as described above, and express.text also accepts defaultCharset
(default utf-8). They are the right tools when the payload is not form data or JSON: binary uploads sent as the request
body, plain text, CSV, or a body whose exact bytes must be preserved.
Because the default type of express.raw is application/octet-stream and of express.text is text/plain, a
client that sends something else is ignored silently, leaving req.body undefined. Declare the types you expect, for
example type: 'text/csv', rather than loosening it to a catch-all wildcard, which would let any request through to a
parser that buffers it entirely in memory.
import { Router } from 'express';
import express from 'express';
export const importRouter = Router();
// A CSV catalogue posted as the raw request body.
importRouter.post('/books/import', express.text({ type: 'text/csv', limit: '1mb' }), (req, res) => {
if (typeof req.body !== 'string') {
res.status(415).json({ error: 'send the catalogue as text/csv' });
return;
}
const rows = req.body.trim().split('\n').slice(1);
res.status(202).json({ accepted: rows.length });
});
// A cover image posted as raw bytes (for multipart uploads use multer instead).
importRouter.put('/books/:id/cover', express.raw({ type: 'image/*', limit: '2mb' }), (req, res) => {
if (!Buffer.isBuffer(req.body)) {
res.status(415).json({ error: 'send the cover as an image body' });
return;
}
res.status(204).end();
});
Source: Express API: express.raw and Express API: express.text
Where to mount a parser
A parser is ordinary middleware, so it can be mounted for the whole application, for a path prefix or for one route. Choosing the narrowest scope that works is both a performance and a security decision.
-
Application-wide:
app.use(express.json())is convenient and common, but every route then accepts a JSON body of the globallimit, including routes that never read one. -
Path-scoped:
app.use('/api', express.json())parses only below/api, and a router can carry its own parser. -
Route-scoped:
app.post('/webhooks/stripe', express.raw(), handler)gives one route its own parser, limit and type.
Order matters. A request body is a stream that can be consumed once. When two parsers both match the same request, the
first one reads the body and marks it as parsed, and the second one silently skips the request, so req.body has the type
the first parser produced (a parsed object where the route expected a Buffer, for example). A 500 error of type
stream.not.readable only appears when code other than a body-parser parser already consumed the stream. This is the origin of the classic webhook problem: a global
express.json() placed before a raw route consumes the body, so the route can no longer read the original bytes. Mount
the specific raw route first, or mount the global parser below it, as in the next section.
Raw bodies for webhook signature verification
Payment, source-control and messaging providers sign webhook payloads with an HMAC computed over the exact bytes they sent. Re-serialising the parsed JSON object produces different bytes (key order, whitespace, number formatting), so the signature can only be checked against the raw body. There are two reliable ways to get it.
The first is a route that uses express.raw and parses JSON itself after verifying. The second keeps express.json and
captures the bytes with the verify option, which is called with the raw Buffer before parsing and may throw to reject
the request. Both are shown below; the secret comes from the environment, never from source code.
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
const secret = process.env.WEBHOOK_SECRET;
if (!secret) throw new Error('WEBHOOK_SECRET is not set');
function signatureMatches(rawBody, header) {
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
const received = Buffer.from(String(header ?? ''), 'hex');
const wanted = Buffer.from(expected, 'hex');
// timingSafeEqual throws on length mismatch, so compare lengths first.
return received.length === wanted.length && timingSafeEqual(received, wanted);
}
export function mountWebhooks(app) {
// Option 1: a raw route, mounted BEFORE any global express.json().
app.post(
'/webhooks/reviews',
express.raw({ type: 'application/json', limit: '256kb' }),
(req, res) => {
if (!Buffer.isBuffer(req.body) || !signatureMatches(req.body, req.get('x-signature'))) {
res.status(401).json({ error: 'invalid signature' });
return;
}
const event = JSON.parse(req.body.toString('utf8'));
res.status(202).json({ received: event.type });
},
);
// Option 2: keep express.json() and capture the raw bytes through verify.
app.use(
'/webhooks/shipping',
express.json({
verify(req, res, buf) {
req.rawBody = buf;
},
}),
);
app.post('/webhooks/shipping', (req, res) => {
if (!req.rawBody || !signatureMatches(req.rawBody, req.get('x-signature'))) {
res.status(401).json({ error: 'invalid signature' });
return;
}
res.status(202).json({ received: req.body?.type });
});
}
Source: Express resources: body-parser
Remember to call mountWebhooks(app) before app.use(express.json()) for the application-wide parser, so the raw route
runs first. Keep the retained raw Buffer as small and short-lived as possible: storing the body of every request keeps
all of it in memory for the life of the request.
Compressed request bodies
All four parsers inflate compressed request bodies when the inflate option is true (the default). They read the
Content-Encoding request header and support gzip and deflate, plus br (Brotli) and zstd in body-parser 2, so
Express 5 can receive Brotli-compressed JSON that Express 4 could not. zstd support depends on Node.js, which provides
it from version 22.15 and 23.8. A body that arrives with an unsupported Content-Encoding, or any compressed body when
inflate is false, is rejected with 415 and error type encoding.unsupported.
The limit applies to the inflated size, not just to the bytes on the wire. A small compressed payload can expand to a
very large one (a decompression bomb), so the limit stays the real defence; it is checked while the body is read. If
an endpoint never needs compressed uploads, inflate: false removes the decompression code path altogether.
import { brotliCompressSync } from 'node:zlib';
import express from 'express';
export const app = express();
app.use(express.json({ limit: '100kb', inflate: true }));
app.post('/api/v1/reviews', (req, res) => res.status(201).json(req.body));
// Client side: send Brotli-compressed JSON to an Express 5 server.
export async function postCompressed(baseUrl) {
const body = brotliCompressSync(Buffer.from(JSON.stringify({ bookId: 1, rating: 5 })));
return fetch(`${baseUrl}/api/v1/reviews`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'content-encoding': 'br' },
body,
});
}
Source: Express resources: body-parser
Errors
Parsing failures are passed to next(err) as errors built with the http-errors package, so they reach your
error-handling middleware or Express’s default handler. Each carries a status and statusCode property, an expose
flag saying whether the message is safe to show a client, and a type string that you can match without comparing
messages. The body property holds the text that failed to parse or verify, when available.
| Status | type |
Cause |
|---|---|---|
400 |
|
The body is not valid for the parser, for example malformed JSON. |
400 |
|
The client closed the connection before the body was fully received. |
400 |
|
The received size did not match |
403 |
|
Your |
413 |
|
The body is larger than |
413 |
|
A form has more fields than |
415 |
|
The |
500 |
|
Another middleware already consumed the body stream. |
500 |
|
Something called |
The default handler would answer a malformed JSON body with an HTML page and, outside production, a stack trace. A JSON
API should translate body-parser errors into its own format, using err.type to decide and only exposing messages
flagged with expose. In Express 5 the handler is a normal four-argument middleware registered last.
export function bodyErrorHandler(err, req, res, next) {
if (res.headersSent) {
next(err);
return;
}
if (typeof err.type === 'string' && err.status >= 400 && err.status < 500) {
const titles = {
'entity.parse.failed': 'The request body is not valid',
'entity.too.large': 'The request body is too large',
'parameters.too.many': 'Too many form fields',
'encoding.unsupported': 'Unsupported content encoding',
'entity.verify.failed': 'The request body failed verification',
};
res
.status(err.status)
.type('application/problem+json')
.json({
type: `https://bookshelf.example/problems/${err.type}`,
title: titles[err.type] ?? 'The request body could not be read',
status: err.status,
});
return;
}
next(err);
}
Source: Express guide: Error handling
Mount it after the routes with app.use(bodyErrorHandler). Error handling in depth, including the default handler and
asynchronous errors, is in Error Handling.
Limits as a defence
Every parser buffers the whole body in memory before your handler runs, so the parser configuration is part of the application’s denial-of-service posture. The practical rules are:
-
Keep the default
limitof 100 kilobytes wherever the API does not need more, and raise it per route, not globally. Thebody-parserdocumentation recommends against very high limits: a few megabytes already increases memory use and response times because of decoding, transformation and validation work. -
Keep
parameterLimitlow on forms. A thousand fields is rarely legitimate. -
Keep
extended: falseunless nesting is needed, and setdepthto the smallest value your forms use when it is on. -
Do not widen
typeto wildcards; unexpected content types should stay unparsed and produce a clear 415 or 422 from your own code. -
Do not rely on the parser alone for very large or slow uploads. Put a body-size limit on the reverse proxy and configure the HTTP server timeouts described in Security Best Practices.
-
Validate after parsing. A parsed body is shaped by the client, not by you.
The qs library that implements extended: true has had denial-of-service and prototype-pollution class fixes over
time, and body-parser and Express raise their qs requirement when they happen. Express 5.3.0 requires qs 6.16 or
newer, with the fixes listed in its History.md. Keep dependencies current with npm audit and a regular
npm update, because these parsers sit on the path of every request. The security advisories and release notes of
expressjs/express and expressjs/body-parser on GitHub are the authoritative list of what was fixed in each version.
A complete Bookshelf example
The following module combines the pieces: webhook route first, global JSON and form parsers with tight limits, an upload
route with its own limit, and the error handler last. It is runnable with node --env-file=.env src/server.js once the
files above exist.
import express from 'express';
import { mountWebhooks } from './webhooks.js';
import { importRouter } from './routes/import.js';
import { bodyErrorHandler } from './errors.js';
const app = express();
app.disable('x-powered-by');
// 1. Raw and signature-checked routes first, so no global parser consumes their body.
mountWebhooks(app);
// 2. Global parsers: small limits, flat forms.
app.use(express.json({ limit: '50kb' }));
app.use(express.urlencoded({ limit: '10kb', parameterLimit: 50 }));
// 3. Routes with their own parsers.
app.use('/api/v1', importRouter);
app.post('/api/v1/books', (req, res) => {
const { title, author } = req.body ?? {};
if (typeof title !== 'string' || typeof author !== 'string') {
res.status(422).json({ error: 'title and author must be strings' });
return;
}
res.status(201).json({ id: 1, title, author });
});
// 4. Error handling last.
app.use(bodyErrorHandler);
const port = Number(process.env.PORT ?? 3000);
app.listen(port, (error) => {
if (error) throw error;
console.log(`Bookshelf listening on port ${port}`);
});
Source: Express: Hello world
A quick check from a terminal shows the three outcomes, success, wrong content type and oversized body:
curl -s -X POST http://localhost:3000/api/v1/books \
-H "content-type: application/json" -d '{"title":"Dune","author":"Frank Herbert"}'
curl -s -X POST http://localhost:3000/api/v1/books -d '{"title":"Dune"}'
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/api/v1/books \
-H "content-type: application/json" --data-binary @large.json
The first returns 201 with the book. The second sends a form body with the default application/x-www-form-urlencoded
content type that the JSON parser ignores, so the form parser reads one nonsense field and the handler answers 422. The
third, for a file over 50 kilobytes, prints 413.
What changed recently
-
Express 4.16 (2017) added
express.jsonandexpress.urlencoded; Express 4.17 addedexpress.rawandexpress.text. Before that, the separatebody-parserpackage was the only option. -
Express 5 depends on
body-parser2.req.bodyis no longer initialised to an empty object,extendeddefaults tofalseforexpress.urlencoded, and Brotli (and, where Node.js supports it, zstd) request bodies are inflated. -
The
depthoption (default 32) boundsqsnesting, andparameterLimitrejects forms with too many fields with 413. -
The
qsrequirement keeps moving up with security fixes; Express 5.3.0 requiresqs6.16 or newer. -
The
body-parserpackage can still be installed directly, and is the way to get parsers outside Express, but for an Express application the built-ins are enough.
Where the books are outdated
The four reference books target Express 4 and earlier tooling. The statements below are about body parsing; the complete list lives in Migrating and What’s Changed.
| Book practice | Current Express |
|---|---|
Calling a bare |
Removed long ago; mount |
Installing and requiring the separate |
Replaced by the built-ins |
Writing |
Express 5 defaults |
Assuming |
It is |
Handling uploads with the body parser |
Multipart bodies are not parsed by these middleware; use multer or another multipart parser. |
No limits and no error handler for malformed bodies |
Set a |
References
-
Express API: express() (
express.json,express.urlencoded,express.raw,express.text)