The Request Object (req)

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 (npm express 5.3.0, published 2026-10-09) on Node.js 18 or later, the minimum for Express 5, and it states where Express 4 (latest 4.22.3) behaves differently. The behaviour described was checked on 2026-10-29 against the official API reference and against the 5.3.0 source of lib/request.js and of the router 2.x package. The req object is what a handler receives as its first argument. It is Node’s http.IncomingMessage with the Express prototype on top, so everything that Node provides (req.headers, req.url, req.socket, the readable stream interface) is still there, and Express adds convenience getters and methods, most of them thin wrappers around the jshttp modules accepts, type-is, fresh, range-parser and proxy-addr and around parseurl. The examples belong to the Bookshelf API that grows across the section, use ESM and the node: prefix for built-in modules, and contain placeholders only, never real secrets.

The whole surface at a glance

Every Express-specific member of req falls into one of five groups. The detail for each one follows in the sections named in the last column.

Group Members Section

Where the request is in the application

req.app, req.res, req.baseUrl, req.originalUrl, req.path, req.route, req.params

Where the request is in the application, req.params and its null prototype

What the client sent

req.body, req.cookies, req.signedCookies, req.query, req.get() / req.header()

req.query, the getter and the parser, req.body, req.cookies and req.signedCookies, Reading headers: req.get(), req.header() and req.headers

Where the client is and how it connected

req.ip, req.ips, req.protocol, req.secure, req.host, req.hostname, req.subdomains, req.xhr

Client address, protocol and secure connections, req.host, req.hostname and req.subdomains

Caching

req.fresh, req.stale

req.fresh and req.stale

Negotiation and body inspection

req.accepts(), req.acceptsCharsets(), req.acceptsEncodings(), req.acceptsLanguages(), req.is(), req.range()

Content negotiation: req.accepts and its plural siblings, Inspecting the body: req.is(), Partial content: req.range()

Two things are not on the list on purpose. The helper req.param(name) was deprecated in Express 4 and removed in Express 5, so read req.params, req.body and req.query explicitly. The singular req.acceptsCharset(), req.acceptsEncoding() and req.acceptsLanguage() were also removed; only the plural names exist.

A first tour: inspecting one request

The fastest way to learn the object is to print it. The program below mounts a router at /api/v1, which makes the difference between the URL-related properties visible, and echoes the properties back as JSON. Run it with npm install express and node request-tour.mjs.

// request-tour.mjs
import express from 'express';

const app = express();
const router = express.Router();

router.get('/books/:id', (req, res) => {
  res.json({
    method: req.method,
    originalUrl: req.originalUrl,
    baseUrl: req.baseUrl,
    url: req.url,
    path: req.path,
    routePath: req.route.path,
    params: { ...req.params },
    query: { ...req.query },
    protocol: req.protocol,
    secure: req.secure,
    host: req.host,
    hostname: req.hostname,
    ip: req.ip,
    ips: req.ips,
    subdomains: req.subdomains,
    xhr: req.xhr,
    fresh: req.fresh,
    stale: req.stale,
    userAgent: req.get('user-agent') ?? null,
  });
});

app.use('/api/v1', router);

app.listen(3000, (error) => {
  if (error) {
    throw error;
  }
  console.log('Request tour on http://localhost:3000');
});

Call it with the exchange below, for example with curl -i 'http://localhost:3000/api/v1/books/7?expand=reviews&expand=author'.

GET /api/v1/books/7?expand=reviews&expand=author HTTP/1.1
Host: localhost:3000
Accept: application/json

The response body, with the line breaks added for reading, looks like this on a local machine:

{
  "method": "GET",
  "originalUrl": "/api/v1/books/7?expand=reviews&expand=author",
  "baseUrl": "/api/v1",
  "url": "/books/7?expand=reviews&expand=author",
  "path": "/books/7",
  "routePath": "/books/:id",
  "params": { "id": "7" },
  "query": { "expand": ["reviews", "author"] },
  "protocol": "http",
  "secure": false,
  "host": "localhost:3000",
  "hostname": "localhost",
  "ip": "::1",
  "ips": [],
  "subdomains": [],
  "xhr": false,
  "fresh": false,
  "stale": true,
  "userAgent": "curl/8.7.1"
}

Two details in that output deserve attention before the sections below. params and query are copied with the spread operator because both objects have unusual prototypes (see req.params and its null prototype and req.query, the getter and the parser), and host still carries the port :3000, which is new in Express 5 (see req.host, req.hostname and req.subdomains).

Where the request is in the application

Express rewrites req.url while a request travels through mounted routers and sub-applications, so four properties describe the same URL from different positions. Using the wrong one is a classic source of broken redirects and broken mounts.

Property Meaning Value in the tour (GET /api/v1/books/7?expand=reviews)

req.originalUrl

The URL exactly as received, including the query string, whatever the mounting. It never changes.

/api/v1/books/7?expand=reviews

req.baseUrl

The mount path of the router or sub-application that is currently handling the request. It is the empty string for handlers registered directly on the application.

/api/v1

req.url

Node’s own property: the URL with the mount path removed and the query string kept. Express edits it while it descends into a mounted router and restores it afterwards.

/books/7?expand=reviews

req.path

The pathname part of req.url, so relative to the mount and without the query string. A getter implemented with parseurl.

/books/7

Use req.originalUrl for logging and for redirects that must come back to the same address, and req.baseUrl plus a relative path to build links that stay correct if the router is mounted somewhere else. Avoid assigning to req.url in application code; if you must rewrite a request (for instance to implement a short alias), do it in an early middleware and expect later layers to match against the new value. The mounting rules are covered in Routers & Modular Apps.

The remaining members of this group are references:

  • req.app is the application that handles the request, which gives middleware access to req.app.get('env'), req.app.locals and settings without importing the application module (and so without circular imports).

  • req.res is the response object, symmetrical to res.req.

  • req.route is the currently matched Route object, with the registered path, the stack of handlers and the methods it answers. It exists only inside route handlers, not in app.use() layers, which run before a route is chosen. It is useful for logging and metrics because it holds the route template (/books/:id) instead of the URL with a user-controlled identifier in it, which keeps metric labels low in cardinality.

req.params and its null prototype

req.params holds the values of the named segments of the matched path. The path syntax belongs to path-to-regexp 8 (reached through router 2.x); the syntax itself is in Route Paths & Parameters. What matters for req is the shape of the object, and four properties of that shape changed or need care in Express 5:

  1. No inherited properties. For string paths the router creates the object with a null prototype, so it has no hasOwnProperty, toString or constructor. Calling req.params.hasOwnProperty('id') throws a TypeError, as does interpolating req.params itself into a template literal. Use Object.hasOwn(req.params, 'id'), the in operator, Object.keys() or a spread into a normal object. Do not rely on the prototype being absent or present when a router uses mergeParams, where the object is merged and may be rebuilt; write code that works for both.

  2. Unmatched optional parameters are omitted. With an optional segment such as /books{/:id}, a request for /books yields an object without the key id. In Express 4 the key existed with the value undefined.

  3. Wildcards are arrays. A named wildcard /files/*splat captures the remaining path segments as an array of strings, one per segment, so a request for /files/a/b/c.txt gives { splat: ['a', 'b', 'c.txt'] }. In Express 4 the unnamed wildcard was one string under the key 0.

  4. Values are decoded strings. The router runs decodeURIComponent on every captured segment, so %2F becomes / and %2E%2E becomes .. inside a segment. A malformed escape such as %E0%A4%A makes the router fail with a 400 error before any handler runs. Always treat params as untrusted text (see What is user-controlled and must be validated).

The example validates an identifier, shows the safe way to test for a key, and serves a nested path under a wildcard with a traversal check.

// request-params.mjs
import { posix } from 'node:path';
import express from 'express';

const app = express();

// Router-level parameter callback: runs once per request whenever :id is in the path.
app.param('id', (req, res, next, value) => {
  if (!/^[1-9]\d{0,8}$/.test(value)) {
    return res.status(400).json({ title: 'Invalid identifier', status: 400 });
  }
  next();
});

app.get('/api/v1/books/:id', (req, res) => {
  // req.params has a null prototype: req.params.hasOwnProperty would throw.
  const hasId = Object.hasOwn(req.params, 'id'); // always true here
  res.json({ id: Number(req.params.id), hasId, keys: Object.keys(req.params) });
});

// Optional segment: the key "section" is absent, not undefined, when the segment is missing.
app.get('/api/v1/library{/:section}', (req, res) => {
  const section = Object.hasOwn(req.params, 'section') ? req.params.section : 'all';
  res.json({ section });
});

// Named wildcard: req.params.assets is an array of decoded segments.
app.get('/covers/*assets', (req, res) => {
  const segments = req.params.assets;
  // Decoded segments can contain "..", "/" or backslashes even though the URL path did not.
  const unsafe = segments.some((s) => s === '..' || s === '.' || s.includes('/') || s.includes('\\') || s.includes('\0'));
  if (unsafe) {
    return res.status(400).json({ title: 'Invalid path', status: 400 });
  }
  // Join with the posix helper so the result never depends on the host operating system.
  res.json({ requested: posix.join(...segments) });
});

app.listen(3000, (error) => {
  if (error) {
    throw error;
  }
});

Three more rules about params. The key names come from the path, never from the client, so req.params cannot be used to inject arbitrary keys the way a parsed body can; its values, however, are as hostile as any input. app.param() and router.param() callbacks run before the route handlers and receive the decoded value as their fourth argument, which makes them a good place for the format check shown above. And a router created without mergeParams: true does not see the params of the path it is mounted at (app.use('/authors/:authorId/books', router) leaves req.params.authorId undefined inside the router); set mergeParams: true when it should.

req.query, the getter and the parser

req.query is the parsed query string of req.url. Three facts define its behaviour in Express 5, and the 5.3.0 source confirms all three.

  1. It is a getter, not a data property. It is defined on the request prototype and computes its value every time it is read. Reading req.query twice parses the string twice and returns two different objects. Consequently an assignment such as req.query.page = 1 changes a throwaway copy and is lost, and req.query = sanitise(req.query) fails because there is no setter. In Express 4 req.query was set once, early, by the built-in query middleware, so both patterns worked. Read it once (const { page, sort } = req.query;) and keep the validated result elsewhere, in a local variable, res.locals or a property of your own such as req.validated.

  2. The default parser is simple. Express 4 defaulted to the extended parser (the qs package); Express 5 defaults to simple (the Node.js node:querystring module). The setting is called query parser and is read with app.get('query parser fn').

  3. When the parser is disabled the value is an empty object with a null prototype. Setting query parser to false makes req.query an empty object (created with Object.create(null)), which is the fastest option if the API uses no query strings at all.

The two built-in parsers differ in what they return for the same input:

Query string simple (default in 5) extended (default in 4)

page=2

{ page: '2' }

{ page: '2' }

tag=sf&tag=classic

{ tag: ['sf', 'classic'] }

{ tag: ['sf', 'classic'] }

tag[]=sf

{ 'tag[]': 'sf' }

{ tag: ['sf'] }

filter[genre]=sf

{ 'filter[genre]': 'sf' }

{ filter: { genre: 'sf' } }

a[b][c]=1

{ 'a[b][c]': '1' }

{ a: { b: { c: '1' } } }

page with no value

{ page: '' }

{ page: '' }

Points that follow from the table:

  • Every value is a string or an array of strings in simple mode. A client that repeats a key turns a string into an array, so req.query.sort.toUpperCase() throws for ?sort=a&sort=b. Validate the type, not just the content.

  • The simple result also has a null prototype (it comes from querystring.parse), so it has no hasOwnProperty, just like req.params. Copy it into a normal object with a spread when a library expects one.

  • The extended parser builds nested objects and arrays from the bracket syntax. Express passes allowPrototypes: true to qs, and qs applies its defaults for the rest: a nesting depth of 5, a parameterLimit of 1000 and an arrayLimit of 20. That is convenient for filters (filter[genre]=sf) and dangerous when the object is passed straight to a database driver: ?author[$ne]= becomes the object { author: { $ne: '' } }, a NoSQL operator injection when forwarded to MongoDB. Never pass req.query or its parts to a query unchecked.

  • Choose the parser per application with one setting. app.set('query parser', 'extended') restores the Express 4 behaviour; app.set('query parser', fn) installs a function that takes the raw query string (without the leading ?) and returns an object, for example qs.parse with explicit limits. A value other than true, false, 'simple', 'extended' or a function throws at app.set() time. The settings are listed in Application & Settings.

The example reads the query once, validates every field and rebuilds a safe filter, with the extended parser enabled for the bracket syntax of filter[genre].

// request-query.mjs
import express from 'express';
import qs from 'qs';

const app = express();

// Replace the default simple parser with qs and limits tighter than its defaults.
app.set('query parser', (str) => qs.parse(str, { depth: 2, arrayLimit: 10, parameterLimit: 50 }));

const SORTS = new Set(['title', '-title', 'year', '-year']);
const GENRES = new Set(['sf', 'fantasy', 'mystery', 'history']);

function problem(res, detail) {
  return res.status(400).json({ title: 'Invalid query', status: 400, detail });
}

app.get('/api/v1/books', (req, res) => {
  // Read the getter once: every access would parse the query string again.
  const { page = '1', pageSize = '20', sort = 'title', filter, tag } = req.query;

  for (const [name, value] of Object.entries({ page, pageSize, sort })) {
    if (typeof value !== 'string') {
      return problem(res, `${name} must appear once`);
    }
  }
  if (!/^[1-9]\d{0,5}$/.test(page) || !/^[1-9]\d{0,2}$/.test(pageSize)) {
    return problem(res, 'page and pageSize must be positive integers');
  }
  if (Number(pageSize) > 100) {
    return problem(res, 'pageSize must not exceed 100');
  }
  if (!SORTS.has(sort)) {
    return problem(res, `sort must be one of ${[...SORTS].join(', ')}`);
  }

  // A nested value from the bracket syntax is an object only when the client sends one.
  let genre;
  if (filter !== undefined) {
    if (typeof filter !== 'object' || filter === null || Array.isArray(filter)) {
      return problem(res, 'filter must use filter[genre]=value');
    }
    genre = filter.genre;
    if (typeof genre !== 'string' || !GENRES.has(genre)) {
      return problem(res, 'unknown genre');
    }
  }

  // tag may be missing, a string or an array of strings: normalise it.
  const tags = tag === undefined ? [] : [tag].flat();
  if (!tags.every((t) => typeof t === 'string' && /^[\w-]{1,30}$/.test(t))) {
    return problem(res, 'tag values must be short words');
  }

  // Only validated values leave this function; the raw query never reaches the data layer.
  const criteria = { page: Number(page), pageSize: Number(pageSize), sort, genre, tags };
  res.json({ criteria, items: [] });
});

app.listen(3000, (error) => {
  if (error) {
    throw error;
  }
});

The qs package is installed separately (npm install qs); it is a dependency of Express, but a direct import should be declared in your own package.json. The same pattern, extended with Zod or express-validator schemas, is the subject of Validation & Input Handling.

req.body, req.cookies and req.signedCookies

These three properties are filled in by middleware, not by Express itself, and are undefined (or absent) until that middleware has run.

  • req.body is undefined in Express 5 until a body parser (express.json(), express.urlencoded(), express.raw() or express.text()) parses the request. The parsers also leave it undefined when the request has no body or a Content-Type they do not handle; Express 4 parsers set it to {} in those cases. Code that reads req.body.title must therefore guard (req.body?.title) or, better, reject the request with 415 or 400 first. The options of each parser are in Body Parsing.

  • req.cookies is filled in by cookie-parser and is undefined without it. With the middleware but without a cookie header it is an empty object.

  • req.signedCookies is filled in by cookie-parser when it received a secret. A cookie whose signature does not verify is not copied there, so a missing key means "absent or forged". Cookie options and secret rotation are in Cookies & Sessions.

Because all three are plain user input once parsed, they are subject to everything in What is user-controlled and must be validated.

Reading headers: req.get(), req.header() and req.headers

req.get(name) and its alias req.header(name) return the value of a request header, matching the name case-insensitively, or undefined when the header is absent. Node already lower-cases every name in req.headers, so req.headers['content-type'] is the equivalent raw access. The Express method adds three behaviours:

  • Referer and Referrer are interchangeable (the historical misspelling in HTTP is Referer), so either name finds the header.

  • A missing or non-string argument throws a TypeError, which catches typing mistakes early.

  • The value is the string Node assembled; for headers that may repeat, Node joins most of them with a comma (and keeps Set-Cookie as an array), so treat list-valued headers as strings to split yourself.

Header values are client-controlled. User-Agent, Referer, Origin, Content-Type, Accept-Language and every X-* header can say anything, and so can Host and the X-Forwarded-* family unless the request came through a proxy that you trust (see req.host, req.hostname and req.subdomains and Behind Proxies).

Client address, protocol and secure connections

These properties describe the connection. All of them depend on the trust proxy setting, because behind a reverse proxy the socket belongs to the proxy, not to the client.

Property Without trust proxy (default false) With trust proxy trusting the socket address

req.ip

The remote address of the socket. For a client on the same machine this is ::1 or ::ffff:127.0.0.1 (an IPv4 address in IPv6 notation is normal on dual-stack servers).

The address from X-Forwarded-For that proxy-addr selects as the first untrusted hop, falling back to the socket.

req.ips

An empty array.

The trusted-proxy addresses plus the client, ordered from the farthest hop (client) to the nearest proxy; the socket address itself is removed.

req.protocol

https when the socket is TLS (socket.encrypted), otherwise http.

The first value of X-Forwarded-Proto, trimmed, when the header exists; otherwise the socket’s own protocol.

req.secure

req.protocol === 'https'.

The same, evaluated on the protocol above.

The corollary is the central security rule of this group: if you do not set trust proxy, the forwarded headers are ignored; if you set it too widely (true), any client can send X-Forwarded-For: 1.2.3.4 and the application will believe it. That breaks IP-based rate limiting, allow lists and audit logs, and it makes secure cookies behave incorrectly. Set trust proxy to the exact number of hops or to the subnet of the proxy. Every value, with the Nginx, cloud load balancer and Kubernetes ingress setups and the 2026 proxy-addr fix, is in Behind Proxies.

req.xhr is true when the X-Requested-With header equals XMLHttpRequest, compared case-insensitively. The header is set by jQuery and a few other libraries, not by fetch(), and any client can send or omit it, so it is a hint for choosing between an HTML fragment and JSON, never a security check. Use the Accept header (see Content negotiation: req.accepts and its plural siblings) to decide the representation.

req.host, req.hostname and req.subdomains

The three host-related properties are derived from the Host header, or from X-Forwarded-Host when trust proxy trusts the socket address (the first value is used if the header is a comma-separated list).

Property Meaning Example for Host: books.example.com:8443

req.host

The host exactly as given, including the port when one is present. Returns undefined when there is no host header.

books.example.com:8443

req.hostname

The host without the port. IPv6 literals in brackets are handled, so [::1]:3000 gives [::1].

books.example.com

req.subdomains

An array of the labels left of the registered domain, ordered from the one closest to the domain outwards. The subdomain offset setting (default 2) says how many labels at the right belong to the domain. An IP address is not split into labels, so it gives an empty array.

['books'] with the default offset of 2 (the last two labels, com and example, are the domain)

The Express 5 change to know is that req.host keeps the port. In Express 4, req.host was a deprecated alias of req.hostname and stripped it. Code that built absolute URLs from req.host now gets the port as well (which can be correct or wrong depending on the deployment), and code that compared req.host against an allow list of bare names silently stops matching on non-default ports. Use req.hostname for names, req.host for host:port, and keep the distinction in your head.

The more important lesson is that the Host header is client-controlled unless a trusted proxy sets or overwrites it. Building a password-reset link or an OAuth redirect from req.host lets an attacker who sends Host: evil.example poison the e-mail that is sent to a victim (a host-header attack). Build such URLs from a configured public base URL, and reject unknown hosts early. The example does both, and also uses req.subdomains to select a tenant.

// request-host.mjs
import express from 'express';

const app = express();

// "acme.bookshelf.test" has the registered domain "bookshelf.test" (2 labels), so the default offset works.
app.set('subdomain offset', 2);

const ALLOWED_HOSTNAMES = new Set(['bookshelf.test', 'api.bookshelf.test', 'localhost']);
const TENANTS = new Set(['acme', 'globex']);

// Reject requests for hosts the application is not meant to serve.
app.use((req, res, next) => {
  const known = ALLOWED_HOSTNAMES.has(req.hostname) || (req.subdomains.length === 1 && TENANTS.has(req.subdomains[0]));
  if (!known) {
    return res.status(400).json({ title: 'Unknown host', status: 400 });
  }
  next();
});

// Subdomain-per-tenant: acme.bookshelf.test -> req.subdomains is ['acme'].
app.use((req, res, next) => {
  res.locals.tenant = req.subdomains.length === 1 ? req.subdomains[0] : null;
  next();
});

app.get('/', (req, res) => {
  res.json({
    host: req.host, // includes the port, for example "localhost:3000"
    hostname: req.hostname, // never includes the port
    tenant: res.locals.tenant,
  });
});

// Never use the Host header to build links that leave the application.
const PUBLIC_URL = process.env.PUBLIC_URL ?? 'http://localhost:3000';

app.post('/api/v1/password-resets', express.json(), (req, res) => {
  const token = 'replace-with-a-generated-token';
  const link = new URL(`/reset/${token}`, PUBLIC_URL); // not `${req.protocol}://${req.host}/...`
  res.status(202).json({ sent: true, linkHost: link.host });
});

app.listen(3000, (error) => {
  if (error) {
    throw error;
  }
});

To try the tenant part locally, send a request with an explicit header, for example curl -H 'Host: acme.bookshelf.test' http://localhost:3000/. The tenant lookup shown is deliberately a plain Set; a real application would load tenants from storage and cache the result.

req.fresh and req.stale

req.fresh answers one question: does the client’s cached copy still match what this response would say? It compares the request’s conditional headers (If-None-Match, If-Modified-Since and Cache-Control: no-cache) with the response’s ETag and Last-Modified headers, using the fresh module, and req.stale is simply !req.fresh. Because it reads the response, the order matters:

  • Set ETag and/or Last-Modified on the response first, then read req.fresh. With no validators set, it is always false.

  • It returns false for any method other than GET and HEAD. Express 5.3 added the HTTP QUERY method to that list.

  • It returns false unless the response status code is 2xx or 304, so set an error status before you ask and the answer is false.

res.send() and res.json() already do this for you: they generate a weak ETag (the etag setting) and convert a fresh request into a 304 Not Modified response automatically. Check req.fresh yourself when you want to avoid the expensive part of the handler, such as a database read, when the cached copy is still good.

// request-fresh.mjs
import express from 'express';

const app = express();

// A stand-in for storage: the version number changes with every update.
const book = { id: '1', title: 'Dune', version: 3, updatedAt: new Date('2026-10-01T10:00:00Z') };

app.get('/api/v1/books/1', (req, res) => {
  // 1. Validators first; req.fresh reads them from the response.
  res.set({
    ETag: `"book-${book.id}-v${book.version}"`,
    'Last-Modified': book.updatedAt.toUTCString(),
    'Cache-Control': 'private, max-age=0, must-revalidate',
  });

  // 2. A matching If-None-Match / If-Modified-Since means the client already has this version.
  if (req.fresh) {
    return res.status(304).end();
  }

  // 3. Otherwise send the full representation (this is the expensive branch).
  res.json(book);
});

app.listen(3000, (error) => {
  if (error) {
    throw error;
  }
});

Request the book once, copy the ETag from the response and repeat the request with curl -i -H 'If-None-Match: "book-1-v3"' http://localhost:3000/api/v1/books/1: the second answer is a 304 with no body. The full conditional-GET flow, Vary, weak versus strong ETags and Cache-Control for static files are in Content Negotiation & HTTP Caching.

Content negotiation: req.accepts and its plural siblings

HTTP lets the client list what it can handle (Accept, Accept-Charset, Accept-Encoding, Accept-Language) with quality values (q=0.8). Express exposes four methods built on the accepts module, which in turn uses negotiator. Each takes one argument, several arguments or an array of candidates and returns the best match, or false when nothing is acceptable.

Method Header read Example

req.accepts(types)

Accept

req.accepts(['json', 'html']) returns 'json', 'html' or false. Candidates can be extension names (json) or full MIME types (application/json, text/*).

req.acceptsCharsets(charsets)

Accept-Charset

req.acceptsCharsets('utf-8')

req.acceptsEncodings(encodings)

Accept-Encoding

req.acceptsEncodings(['br', 'gzip'])

req.acceptsLanguages(languages)

Accept-Language

req.acceptsLanguages(['es', 'en'])

Behaviour to remember:

  • The candidate order breaks ties: when the client has no preference between two candidates, the first one in your array wins, so put your preferred representation first.

  • A request without the corresponding header accepts everything, so the call returns the first candidate.

  • The result is the candidate you passed (its spelling), not what the client wrote. req.accepts('application/json') returns 'application/json' and req.accepts('json') returns 'json'.

  • A call with no arguments returns the whole list of types the client accepts, ordered by preference. That is handy for logging, rarely for logic.

  • If nothing matches, answer 406 Not Acceptable (or fall back to a default representation, which the HTTP specification allows), and always add a Vary header naming the request header you used, so shared caches do not serve a JSON body to a client that asked for HTML. res.vary() appends to an existing Vary correctly.

  • res.format() wraps req.accepts() in a handler-per-type object; it is covered with the response API in The Response Object.

  • The singular methods req.acceptsCharset(), req.acceptsEncoding() and req.acceptsLanguage() were removed in Express 5.

Compression is a special case: the compression middleware already negotiates Accept-Encoding, so application code rarely needs req.acceptsEncodings().

Inspecting the body: req.is()

req.is(type) checks the request’s Content-Type against one or more expected types using the type-is module. It is the right guard before reading or trusting the body. The return value has three outcomes:

  • the matching type string (as you spelled the candidate, for example 'json') when it matches;

  • false when the request has a body whose type does not match;

  • null when the request has no body at all (no Transfer-Encoding and a Content-Length of zero or absent).

The candidates may be a short extension (json, html, urlencoded, multipart), a full type (application/json), a wildcard (image/*, */json) or a structured-syntax suffix match such as application/*+json; with several arguments or an array the first match wins. Parameters such as ; charset=utf-8 are ignored. The guard below returns the correct status for each outcome, and the example then negotiates the response representation.

// request-negotiation.mjs
import express from 'express';

const app = express();

/** Accepts only requests whose body matches one of the given types. */
function requireBodyType(types) {
  return function requireBodyTypeMiddleware(req, res, next) {
    const matched = req.is(types);
    if (matched === null) {
      return res.status(400).json({ title: 'A request body is required', status: 400 });
    }
    if (matched === false) {
      res.set('Accept-Post', types.map((t) => (t.includes('/') ? t : `application/${t}`)).join(', '));
      return res.status(415).json({ title: 'Unsupported Media Type', status: 415 });
    }
    next();
  };
}

const books = new Map([['1', { id: '1', title: 'Dune', author: 'Frank Herbert' }]]);

app.get('/api/v1/books/:id', (req, res) => {
  // The first candidate wins ties, so list the preferred representation first.
  const type = req.accepts(['json', 'html', 'text']);
  if (!type) {
    return res.status(406).json({ title: 'Not Acceptable', status: 406 });
  }
  // Pick a language the client accepts; fall back to English.
  const language = req.acceptsLanguages(['es', 'en']) || 'en';

  // The representation depends on two request headers, so say so for caches.
  res.vary('Accept');
  res.vary('Accept-Language');
  res.set('Content-Language', language);

  const book = books.get(req.params.id);
  if (!book) {
    return res.status(404).json({ title: 'Not Found', status: 404 });
  }
  switch (type) {
    case 'json':
      return res.json(book);
    case 'html':
      return res.type('html').send(`<h1>${book.title.replace(/[&<>]/g, '')}</h1>`);
    default:
      return res.type('text').send(`${book.title} by ${book.author}`);
  }
});

app.post('/api/v1/books', requireBodyType(['json']), express.json(), (req, res) => {
  const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
  if (!title) {
    return res.status(400).json({ title: 'title is required', status: 400 });
  }
  const book = { id: String(books.size + 1), title, author: 'Unknown' };
  books.set(book.id, book);
  res.status(201).location(`/api/v1/books/${book.id}`).json(book);
});

app.listen(3000, (error) => {
  if (error) {
    throw error;
  }
});

Note the order in the POST route: the guard runs before express.json(), so a wrong content type is rejected without reading the body. Neither the guard nor the parser replaces validation of the parsed data.

Partial content: req.range()

req.range(size[, options]) parses the Range request header against a resource of size bytes, using range-parser. Three kinds of result are possible:

Return value Meaning

undefined

The request has no Range header (or the header is empty): send the whole resource with 200.

an array of { start, end } objects with a type property

One or more satisfiable ranges. Both bounds are inclusive and already clamped to size - 1, so bytes=0- for a 1000-byte resource is { start: 0, end: 999 }. The type is 'bytes' for the usual header and can be another unit for custom units, which you should ignore unless you support them. Answer 206 Partial Content with a Content-Range.

-1

A syntactically valid header whose ranges cannot be satisfied (all start beyond the end of the resource). Answer 416 Range Not Satisfiable with Content-Range: bytes */size.

-2

A malformed header. The HTTP specification allows a server to ignore a bad Range and answer 200 with the full body.

The only option is combine: true, which merges overlapping and adjacent ranges into one, a cheap defence against the overlapping-range denial-of-service pattern (a client asking for hundreds of ranges that overlap each other).

You rarely implement this by hand for files: res.sendFile(), res.download() and express.static() hand the work to send, which parses Range and answers 206 itself, and an ETag or Last-Modified from the response is honoured through If-Range. req.range() is for content that is not a file on disk: a buffer built in memory, a database blob, a generated export. The example serves one in-memory resource with single-range support and falls back to the whole body for multi-range requests, which the specification permits.

// request-range.mjs
import express from 'express';

const app = express();

// A resource that is not a file on disk: 5500 bytes of text built in memory.
const content = Buffer.from('Bookshelf export line\n'.repeat(250));

app.get('/api/v1/exports/books.txt', (req, res) => {
  const size = content.length;
  res.set({ 'Accept-Ranges': 'bytes', 'Content-Type': 'text/plain; charset=utf-8' });

  const ranges = req.range(size, { combine: true });

  // No Range header, a malformed one, an unsupported unit or several ranges: send everything.
  if (ranges === undefined || ranges === -2 || (Array.isArray(ranges) && (ranges.type !== 'bytes' || ranges.length > 1))) {
    return res.status(200).send(content);
  }

  // The header was valid but no part of it can be satisfied.
  if (ranges === -1) {
    res.set('Content-Range', `bytes */${size}`);
    return res.status(416).end();
  }

  // Exactly one satisfiable byte range.
  const { start, end } = ranges[0];
  res.status(206).set('Content-Range', `bytes ${start}-${end}/${size}`).send(content.subarray(start, end + 1));
});

app.listen(3000, (error) => {
  if (error) {
    throw error;
  }
});

Try it with curl -i -H 'Range: bytes=0-21' http://localhost:3000/api/v1/exports/books.txt, which returns a 206 with the first 22 bytes, and with -H 'Range: bytes=9000-', which returns a 416. res.send() with a buffer sets Content-Length itself.

What is user-controlled and must be validated

Everything on req that comes from the network is input. The table separates the members by how much you can trust them and says what to do. "Trusted" means "set by your own infrastructure under a correct configuration", not "safe to use unchecked".

Member Controlled by What to do

req.body, req.params values, req.query

The client

Validate type, length and format with a schema (see Validation & Input Handling). Allow-list the fields you accept instead of spreading the object into a model, which prevents mass assignment. Never pass nested objects to a database query unchecked.

req.cookies, req.signedCookies

The client (signed cookies: the client can read but not forge them without the secret)

Treat the value as untrusted even when the signature verifies; it proves origin, not correctness. Rotate secrets with an array.

req.get() / req.headers: Host, X-Forwarded-*, Origin, Referer, User-Agent, Content-Type, X-Requested-With

The client, unless a trusted proxy overwrites them

Do not make security decisions from them without a trusted proxy. Do not log them unfiltered if they can contain control characters or personal data.

req.host, req.hostname, req.subdomains, req.protocol, req.secure, req.ip, req.ips

The client, or a trusted proxy when trust proxy is correct

Configure trust proxy exactly (see Behind Proxies). Build outgoing links from configuration, not from the host.

req.path, req.originalUrl, req.url, req.baseUrl

The client (path text); the mount path comes from your code

Never pass them to a file API or a redirect target without validation; encoded characters in params are already decoded.

req.xhr

The client

A hint only; never a defence against CSRF.

req.accepts*(), req.is(), req.range(), req.fresh

The client headers, interpreted by Express

Safe to branch on; cap the work they cause (for example with combine: true for ranges).

req.route, req.app, req.res, req.method

Your code, or the client’s HTTP method choice

Reliable; allow-list methods where it matters.

The broader hardening list is in Security Best Practices.

Typing req in TypeScript

Request is generic: Request<Params, ResBody, ReqBody, Query>. The type of req.params for string paths is a plain record, which hides the null prototype, and /files/*splat yields string[], not string. Declaring the query type does not validate anything; it only trusts you. The patterns for typing and for augmenting req are in TypeScript with Express.

What changed recently

Version Change that affects the request object

Express 5.0.0 (2024-09)

req.param() removed; the singular acceptsCharset/Encoding/Language removed. req.body is undefined unless a parser ran. req.query is a getter (assignment fails) and the default parser is simple. req.host keeps the port, and req.hostname strips it. req.params has no inherited properties, unmatched optional params are omitted and wildcard values are arrays. res.redirect('back') and res.location('back') are removed. Path syntax moved to path-to-regexp 8.

Express 5.1.0 (2025-03-31)

The first 5.x release published under the latest dist-tag.

Express 5.2.0 and 5.2.1 (2025-12-01)

Dependency and security updates; check History.md in the repository for the details of each release.

Express 5.3.0 (2026-10-09)

req.fresh also accepts the HTTP QUERY method, matching the new app.query(). qs and proxy-addr were updated for security fixes, which affect extended query parsing and req.ip / req.ips behind proxies.

The Express team has not announced a version 6, and nothing on this page depends on one.

Where the books are outdated

The four books consulted for this section target Express 4.0 to 4.18, and their request-handling code needs the following corrections.

Older practice Where it appears Current behaviour

Reading identifiers with req.param('id').

Express 3 and early Express 4 code in general

Removed in Express 5. Read req.params, req.query or req.body explicitly.

Taking the new user’s role (or any other field) straight from req.body when registering an account.

Sharma, chapter 6

Mass assignment: allow-list the accepted fields and set the role on the server. See What is user-controlled and must be validated.

Leaving author (or another owned field) in an update object built from req.body, so a post can be reassigned.

Bugl, chapter 6

Strip fields the client may not change; take the owner from the authenticated user, not from the body.

Passing a numeric id from req.params to findById without checking the format.

Sharma, chapter 3

Validate the identifier (app.param() or a schema) and answer 400; a database cast failure is not a client error message.

Building a file name from req.file.originalname and writing it into a public folder, and trusting the client’s MIME type.

Monteiro 2016, chapter 3

The name and the declared type are client input: generate the file name, verify the content, store outside public/.

Using req.query with nested bracket filters without checking, and assuming the default extended parser.

The books' query-string filters (Express 4 default)

The default is simple in Express 5, so filter[genre] is no longer an object; opt in with query parser and validate, because nested objects enable operator injection.

Installing body-parser separately and assuming req.body is always an object.

All four books

express.json() and express.urlencoded() are built in; req.body is undefined when nothing parsed the request.

Using req.host as a bare host name, or building absolute links from the Host header.

Express 4-era examples in general

req.host now includes the port; use req.hostname, and build links from configuration.

Trusting X-Forwarded-For or enabling trust proxy with true on a PaaS without understanding the hop count.

Bugl and Monteiro 2016 deployment chapters

Set the exact hop count or subnet; see Behind Proxies.