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 |
|
Where the request is in the application, req.params and its null prototype |
What the client sent |
|
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 |
|
Client address, protocol and secure connections, req.host, req.hostname and req.subdomains |
Caching |
|
|
Negotiation and body inspection |
|
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');
});
Source: Express API: Request
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"
}
Source: Express API: Request
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) |
|---|---|---|
|
The URL exactly as received, including the query string, whatever the mounting. It never changes. |
|
|
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. |
|
|
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. |
|
|
The pathname part of |
|
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.appis the application that handles the request, which gives middleware access toreq.app.get('env'),req.app.localsand settings without importing the application module (and so without circular imports). -
req.resis the response object, symmetrical tores.req. -
req.routeis the currently matchedRouteobject, with the registeredpath, thestackof handlers and themethodsit answers. It exists only inside route handlers, not inapp.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:
-
No inherited properties. For string paths the router creates the object with a null prototype, so it has no
hasOwnProperty,toStringorconstructor. Callingreq.params.hasOwnProperty('id')throws aTypeError, as does interpolatingreq.paramsitself into a template literal. UseObject.hasOwn(req.params, 'id'), theinoperator,Object.keys()or a spread into a normal object. Do not rely on the prototype being absent or present when a router usesmergeParams, where the object is merged and may be rebuilt; write code that works for both. -
Unmatched optional parameters are omitted. With an optional segment such as
/books{/:id}, a request for/booksyields an object without the keyid. In Express 4 the key existed with the valueundefined. -
Wildcards are arrays. A named wildcard
/files/*splatcaptures the remaining path segments as an array of strings, one per segment, so a request for/files/a/b/c.txtgives{ splat: ['a', 'b', 'c.txt'] }. In Express 4 the unnamed wildcard was one string under the key0. -
Values are decoded strings. The router runs
decodeURIComponenton every captured segment, so%2Fbecomes/and%2E%2Ebecomes..inside a segment. A malformed escape such as%E0%A4%Amakes the router fail with a400error 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.
-
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.querytwice parses the string twice and returns two different objects. Consequently an assignment such asreq.query.page = 1changes a throwaway copy and is lost, andreq.query = sanitise(req.query)fails because there is no setter. In Express 4req.querywas set once, early, by the built-inquerymiddleware, so both patterns worked. Read it once (const { page, sort } = req.query;) and keep the validated result elsewhere, in a local variable,res.localsor a property of your own such asreq.validated. -
The default parser is
simple. Express 4 defaulted to theextendedparser (theqspackage); Express 5 defaults tosimple(the Node.jsnode:querystringmodule). The setting is calledquery parserand is read withapp.get('query parser fn'). -
When the parser is disabled the value is an empty object with a null prototype. Setting
query parsertofalsemakesreq.queryan empty object (created withObject.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) |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Points that follow from the table:
-
Every value is a string or an array of strings in
simplemode. A client that repeats a key turns a string into an array, soreq.query.sort.toUpperCase()throws for?sort=a&sort=b. Validate the type, not just the content. -
The
simpleresult also has a null prototype (it comes fromquerystring.parse), so it has nohasOwnProperty, just likereq.params. Copy it into a normal object with a spread when a library expects one. -
The
extendedparser builds nested objects and arrays from the bracket syntax. Express passesallowPrototypes: truetoqs, andqsapplies its defaults for the rest: a nestingdepthof 5, aparameterLimitof 1000 and anarrayLimitof 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 passreq.queryor 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 exampleqs.parsewith explicit limits. A value other thantrue,false,'simple','extended'or a function throws atapp.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.bodyisundefinedin Express 5 until a body parser (express.json(),express.urlencoded(),express.raw()orexpress.text()) parses the request. The parsers also leave itundefinedwhen the request has no body or aContent-Typethey do not handle; Express 4 parsers set it to{}in those cases. Code that readsreq.body.titlemust therefore guard (req.body?.title) or, better, reject the request with415or400first. The options of each parser are in Body Parsing. -
req.cookiesis filled in bycookie-parserand isundefinedwithout it. With the middleware but without a cookie header it is an empty object. -
req.signedCookiesis filled in bycookie-parserwhen 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:
-
RefererandReferrerare interchangeable (the historical misspelling in HTTP isReferer), 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-Cookieas 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 |
|---|---|---|
|
The remote address of the socket. For a client on the same machine this is |
The address from |
|
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. |
|
|
The first value of |
|
|
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 |
|---|---|---|
|
The host exactly as given, including the port when one is present. Returns |
|
|
The host without the port. IPv6 literals in brackets are handled, so |
|
|
An array of the labels left of the registered domain, ordered from the one closest to the domain outwards. The
|
|
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
ETagand/orLast-Modifiedon the response first, then readreq.fresh. With no validators set, it is alwaysfalse. -
It returns
falsefor any method other thanGETandHEAD. Express 5.3 added the HTTPQUERYmethod to that list. -
It returns
falseunless the response status code is2xxor304, so set an error status before you ask and the answer isfalse.
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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
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'andreq.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 aVaryheader 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 existingVarycorrectly. -
res.format()wrapsreq.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()andreq.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; -
falsewhen the request has a body whose type does not match; -
nullwhen the request has no body at all (noTransfer-Encodingand aContent-Lengthof 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 |
|---|---|
|
The request has no |
an array of |
One or more satisfiable ranges. Both bounds are inclusive and already clamped to |
|
A syntactically valid header whose ranges cannot be satisfied (all start beyond the end of the resource). Answer |
|
A malformed header. The HTTP specification allows a server to ignore a bad |
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 |
|---|---|---|
|
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. |
|
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. |
|
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. |
|
The client, or a trusted proxy when |
Configure |
|
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. |
|
The client |
A hint only; never a defence against CSRF. |
|
The client headers, interpreted by Express |
Safe to branch on; cap the work they cause (for example with |
|
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) |
|
Express 5.1.0 (2025-03-31) |
The first 5.x release published under the |
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) |
|
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 |
Express 3 and early Express 4 code in general |
Removed in Express 5. Read |
Taking the new user’s |
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 |
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 |
Sharma, chapter 3 |
Validate the identifier ( |
Building a file name from |
Monteiro 2016, chapter 3 |
The name and the declared type are client input: generate the file name, verify the content, store outside |
Using |
The books' query-string filters (Express 4 default) |
The default is |
Installing |
All four books |
|
Using |
Express 4-era examples in general |
|
Trusting |
Bugl and Monteiro 2016 deployment chapters |
Set the exact hop count or subnet; see Behind Proxies. |