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

express.json()

JSON text, any Unicode encoding

the value returned by JSON.parse (an object or array when strict is on)

application/json

express.urlencoded()

HTML form bodies (key=value&key2=value2), UTF-8 or ISO-8859-1

an object of keys and values; values are strings or arrays when extended is false

application/x-www-form-urlencoded

express.raw()

any bytes

a Buffer

application/octet-stream

express.text()

text

a string

text/plain

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.

flowchart TD A[Request arrives] --> B{Has a body?} B -- no --> C[req.body stays undefined] B -- yes --> D{Content-Type matches type option?} D -- no --> C D -- yes --> E{Content-Encoding supported and inflate on?} E -- no --> F[415 entity unsupported] E -- yes --> G{Size within limit?} G -- no --> H[413 entity too large] G -- yes --> I{Parses and passes verify?} I -- no --> J[400 parse failed or 403 verify failed] I -- yes --> K[req.body populated] K --> L[next] C --> L

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.

src/app.js
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;
}

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

limit

100kb

Maximum body size. A number is a count of bytes; a string such as 1mb or 200kb is parsed by the bytes package. Larger bodies are rejected with 413.

strict

true

When true, only objects and arrays are accepted at the top level. When false, anything JSON.parse accepts (a quoted string, a number, null) is accepted.

reviver

none

Passed as the second argument of JSON.parse. Use it to turn date strings into Date objects or to drop keys.

type

application/json

Which media types to parse: an extension name (json), a MIME type, a wildcard such as application/*, an array of those, or a function fn(req) that returns a truthy value for requests to parse.

verify

none

verify(req, res, buf, encoding) is called with the raw Buffer before parsing. Throwing aborts the request with 403.

inflate

true

When true, compressed request bodies are decompressed. When false, they are rejected with 415.

defaultCharset

utf-8

Charset assumed when the Content-Type header does not name one.

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.

src/body/json-options.js
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' }));
}

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:

src/body/json-type-function.js
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,
});

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

extended

false in Express 5

false uses the Node.js querystring-style flat parser: values are strings, or arrays for repeated keys. true uses the qs library, which understands nested syntax such as user[name]=Ada and tags[]=a&tags[]=b.

parameterLimit

1000

Maximum number of parameters. A request with more is rejected with 413.

depth

32

With extended: true, the maximum nesting depth qs will parse. Keep it as low as the forms need.

charsetSentinel

false

When true, a form field named utf8 with the value of a check mark selects the charset, which lets a server detect whether a legacy browser submitted the form as ISO-8859-1.

interpretNumericEntities

false

When true, numeric entities such as &#9786; are decoded in ISO-8859-1 forms.

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.

src/body/form-parsers.js
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,
});

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.

src/routes/import.js
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();
});

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 global limit, 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.

src/webhooks.js
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 });
  });
}

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.

src/body/compressed.js
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,
  });
}

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

entity.parse.failed

The body is not valid for the parser, for example malformed JSON.

400

request.aborted

The client closed the connection before the body was fully received.

400

request.size.invalid

The received size did not match Content-Length, often because the header counted characters instead of bytes.

403

entity.verify.failed

Your verify function threw.

413

entity.too.large

The body is larger than limit.

413

parameters.too.many

A form has more fields than parameterLimit.

415

encoding.unsupported

The Content-Encoding is unsupported or inflate is false.

500

stream.not.readable

Another middleware already consumed the body stream.

500

stream.encoding.set

Something called req.setEncoding() before the parser; the parsers need to work on bytes.

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.

src/errors.js
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);
}

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 limit of 100 kilobytes wherever the API does not need more, and raise it per route, not globally. The body-parser documentation recommends against very high limits: a few megabytes already increases memory use and response times because of decoding, transformation and validation work.

  • Keep parameterLimit low on forms. A thousand fields is rarely legitimate.

  • Keep extended: false unless nesting is needed, and set depth to the smallest value your forms use when it is on.

  • Do not widen type to 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.

src/server.js
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}`);
});

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.json and express.urlencoded; Express 4.17 added express.raw and express.text. Before that, the separate body-parser package was the only option.

  • Express 5 depends on body-parser 2. req.body is no longer initialised to an empty object, extended defaults to false for express.urlencoded, and Brotli (and, where Node.js supports it, zstd) request bodies are inflated.

  • The depth option (default 32) bounds qs nesting, and parameterLimit rejects forms with too many fields with 413.

  • The qs requirement keeps moving up with security fixes; Express 5.3.0 requires qs 6.16 or newer.

  • The body-parser package 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 bodyParser() as one middleware to parse everything

Removed long ago; mount express.json() and express.urlencoded() explicitly (Express 4.16 and later), each with a chosen limit.

Installing and requiring the separate body-parser package in every project

Replaced by the built-ins express.json, express.urlencoded, express.raw and express.text, which re-export body-parser.

Writing extended: true by habit, or omitting the option

Express 5 defaults extended to false; set true only when nested fields are needed, and bound depth.

Assuming req.body is always an object

It is undefined when nothing was parsed; read it defensively and validate it.

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 limit, map err.type to a JSON response and register a final error handler.