Input Validation and Sanitization

Every piece of data that enters your Node.js application from outside — a request body, a query string, a header, a file upload, even a message from another service — is untrusted until proven otherwise. Input validation checks that data is the shape and type you expect before you act on it. Sanitization transforms data so it is safe to use in a specific context, such as a database query, an HTML page, or a shell command. Skipping either one is how SQL injection, cross-site scripting (XSS), and prototype pollution end up in production.

Overview: Why Validation and Sanitization Matter

Node.js makes it trivially easy to take a raw string from req.body or req.query and hand it straight to a database driver, a template, or the filesystem. Nothing in the language stops you — JavaScript is dynamically typed, and JSON.parse will happily produce an object with any shape an attacker chooses. The runtime does not protect you here; your code has to.

It helps to separate the two concerns clearly:

  • Validation answers "is this data acceptable?" It rejects the request outright when the answer is no (wrong type, missing field, out-of-range number, malformed email).
  • Sanitization answers "how do I make this data safe to use here?" It transforms accepted data for a specific sink — escaping HTML before rendering it, parameterizing it before sending it to SQL, normalizing it before storing it.

A value can pass validation and still need sanitization. A comment like <b>hello</b> might be perfectly valid as a string, but it is not safe to drop into an HTML page unescaped — that is a sanitization problem, not a validation problem.

Where attacks come from

Attack Where it happens Defense
SQL / NoSQL injection String-built database queries Parameterized queries, ORM query builders
Cross-site scripting (XSS) Unescaped user data rendered into HTML Context-aware escaping, templating engines that auto-escape
Prototype pollution Merging untrusted JSON into objects Reject __proto__/constructor keys, use Map or Object.create(null)
Path traversal User-supplied filenames passed to fs Resolve and check the path stays inside an allowed directory
Command injection User input passed to child_process.exec Avoid shell execution; use execFile with an argument array
ReDoS (regex denial of service) Complex regexes run against attacker-controlled strings Avoid catastrophic backtracking patterns; keep regexes simple and anchored

Validation and sanitization do not eliminate all of these on their own, but they are the first and cheapest line of defense: most injection attacks require getting malformed data past the front door in the first place.

Syntax

There is no single built-in validation function in Node.js — the standard library does not include a schema validator. The common pattern, whether you write it by hand or use a library like zod, looks like this:

const schema = z.object({
  fieldName: z.string().trim().min(1),
});

const result = schema.safeParse(untrustedInput);
if (!result.success) {
  // handle result.error.issues
} else {
  // result.data is validated and typed
}
  • z.object({...}) — describes the expected shape: field names, types, and constraints.
  • .trim(), .min(), .max(), .email() — chained constraints and transforms applied to each field.
  • schema.safeParse(input) — validates without throwing; returns { success, data } or { success, error }.
  • result.data — the validated and sanitized output (trimmed, type-coerced) — use this, not the original raw input.

Examples

Example 1: Hand-rolled validation (no dependencies)

You do not need a library to validate simple input. This example checks a user object by hand, collecting every error instead of stopping at the first one, then produces a sanitized copy.

// validate-user.js
// A small hand-rolled validator with no dependencies.

function validateUser(input) {
  const errors = [];

  if (typeof input !== "object" || input === null) {
    return ["Request body must be a JSON object."];
  }

  const { name, email, age } = input;

  if (typeof name !== "string" || name.trim().length < 2) {
    errors.push("name must be a string with at least 2 characters.");
  }

  if (typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    errors.push("email must be a valid email address.");
  }

  if (typeof age !== "number" || !Number.isInteger(age) || age < 13 || age > 120) {
    errors.push("age must be an integer between 13 and 120.");
  }

  return errors;
}

function sanitizeUser(input) {
  return {
    name: input.name.trim(),
    email: input.email.trim().toLowerCase(),
    age: input.age,
  };
}

const goodInput = { name: "Ada Lovelace", email: "Ada@Example.com ", age: 28 };
const badInput = { name: "A", email: "not-an-email", age: 9 };

for (const candidate of [goodInput, badInput]) {
  const errors = validateUser(candidate);
  if (errors.length === 0) {
    console.log("Valid:", sanitizeUser(candidate));
  } else {
    console.log("Invalid:", errors);
  }
}
Valid: { name: 'Ada Lovelace', email: 'ada@example.com', age: 28 }
Invalid: [
  'name must be a string with at least 2 characters.',
  'email must be a valid email address.',
  'age must be an integer between 13 and 120.'
]

Notice the pattern: every check uses typeof first (never assume a field exists or has the right type), errors accumulate into an array instead of throwing, and sanitization — trimming and lowercasing — happens only after validation passes, on a separate copy of the data.

Example 2: Schema validation with zod

Hand-rolled checks get unwieldy fast once you have nested objects, arrays, or many endpoints. zod is a widely used schema library that declares the shape once and gives you parsing, coercion, and typed error messages for free.

npm install zod
// validate-with-zod.js
import { z } from "zod";

const signupSchema = z.object({
  name: z.string().trim().min(2).max(80),
  email: z.string().trim().toLowerCase().email(),
  age: z.number().int().min(13).max(120),
});

function checkSignup(payload) {
  const result = signupSchema.safeParse(payload);
  if (!result.success) {
    return { ok: false, errors: result.error.issues.map((issue) => issue.message) };
  }
  return { ok: true, data: result.data };
}

console.log(checkSignup({ name: "Grace Hopper", email: " Grace@Example.com", age: 44 }));
console.log(checkSignup({ name: "X", email: "nope", age: 6 }));
{ ok: true, data: { name: 'Grace Hopper', email: 'grace@example.com', age: 44 } }
{
  ok: false,
  errors: [
    'String must contain at least 2 character(s)',
    'Invalid email',
    'Number must be greater than or equal to 13'
  ]
}

The schema does three jobs at once: it validates types and ranges, it sanitizes (.trim(), .toLowerCase()) as part of parsing, and it produces a typed result.data you can trust downstream. safeParse never throws, which keeps validation errors as ordinary control flow instead of exceptions. (joi and express-validator are common alternatives with a similar philosophy.)

Example 3: Validation middleware in a real Express endpoint

In a real app, validation belongs in middleware that runs before the route handler, and sanitized output must still be paired with safe use downstream — here, a parameterized SQL query.

npm install express zod mysql2
// server.js
import express from "express";
import { z } from "zod";
import mysql from "mysql2/promise";

const app = express();
app.use(express.json({ limit: "10kb" }));

const signupSchema = z.object({
  name: z.string().trim().min(2).max(80),
  email: z.string().trim().toLowerCase().email(),
  age: z.number().int().min(13).max(120),
});

function validateBody(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      return res.status(400).json({
        error: "Validation failed",
        details: result.error.issues.map((issue) => issue.message),
      });
    }
    req.body = result.data;
    next();
  };
}

const pool = mysql.createPool({
  host: "localhost",
  user: "app_user",
  password: process.env.DB_PASSWORD,
  database: "app_db",
});

app.post("/signup", validateBody(signupSchema), async (req, res) => {
  const { name, email, age } = req.body;
  try {
    await pool.execute(
      "INSERT INTO users (name, email, age) VALUES (?, ?, ?)",
      [name, email, age]
    );
    res.status(201).json({ message: "User created" });
  } catch (err) {
    console.error("Signup failed:", err.message);
    res.status(500).json({ error: "Internal server error" });
  }
});

const port = process.env.PORT ?? 3000;
app.listen(port, () => console.log(`Listening on port ${port}`));

A POST /signup with a missing age gets a 400 with a details array and never reaches the database. A valid request is trimmed and lowercased by the schema, then inserted with a parameterized query — the values are sent separately from the SQL text, so nothing the client sends can change the query’s structure. express.json({ limit: "10kb" }) also caps body size, which stops an attacker from sending a huge payload to exhaust memory before validation even runs.

How Validation Fits Into a Request — Step by Step

  • 1. The request arrives; express.json() parses the body synchronously against the configured size limit, rejecting oversized payloads before your code runs.
  • 2. The validation middleware runs schema.safeParse(req.body). This is synchronous and cheap — no I/O is involved.
  • 3. If validation fails, the middleware calls res.status(400).json(...) and returns without calling next(). The route handler never runs, and no untrusted data reaches your business logic.
  • 4. If validation succeeds, req.body is replaced with the sanitized, typed result.data, and next() hands control to the route handler.
  • 5. The handler uses the now-trusted data, but still sanitizes for the specific sink it’s writing to — here, a parameterized query rather than string concatenation.

Common Mistakes

Mistake 1: Building SQL with string concatenation

Concatenating user input into a SQL string lets an attacker close your string literal early and inject their own SQL.

// DON'T DO THIS — string concatenation lets an attacker inject SQL
app.post("/signup", async (req, res) => {
  const { name, email } = req.body;
  const sql = "INSERT INTO users (name, email) VALUES ('" + name + "', '" + email + "')";
  await pool.query(sql);
  res.status(201).json({ message: "User created" });
});

An email value like x', (SELECT password FROM admins LIMIT 1)) -- can alter the query’s meaning entirely. Always send values separately from SQL text with placeholders:

const { name, email } = req.body;
await pool.execute(
  "INSERT INTO users (name, email) VALUES (?, ?)",
  [name, email]
);
res.status(201).json({ message: "User created" });

Mistake 2: Merging untrusted JSON without checking keys

Copying every key from a parsed JSON object into an existing object can let an attacker set __proto__, polluting Object.prototype for the entire process.

// DON'T DO THIS — merging unvalidated JSON into an object can pollute the prototype
function setPreferences(target, updates) {
  for (const key in updates) {
    target[key] = updates[key];
  }
  return target;
}

const prefs = {};
const attackerInput = JSON.parse('{"__proto__": {"isAdmin": true}}');
setPreferences(prefs, attackerInput);

console.log({}.isAdmin); // true on every object in the process!

Validating against a schema (Examples 2 and 3) avoids this automatically, because unknown keys like __proto__ are never copied. If you must merge by hand, explicitly reject dangerous keys:

const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);

function setPreferences(target, updates) {
  for (const key of Object.keys(updates)) {
    if (UNSAFE_KEYS.has(key)) continue;
    target[key] = updates[key];
  }
  return target;
}

Mistake 3: Blacklisting characters instead of escaping output

Trying to strip "bad" substrings is easy to bypass, because there are countless equivalent payloads a blacklist won’t anticipate.

// DON'T DO THIS — blacklisting a few characters is easy to bypass
function sanitizeComment(input) {
  return input.replace(/<script>/gi, "");
}

app.get("/comment", (req, res) => {
  const comment = sanitizeComment(req.query.text);
  res.send("<p>User said: " + comment + "</p>");
});

A payload like <img src=x onerror=alert(1)> never contains the literal text <script>, so it sails straight through. Escape the characters that are dangerous in the target context instead of trying to guess payload patterns:

function escapeHtml(input) {
  return input
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

app.get("/comment", (req, res) => {
  const comment = escapeHtml(req.query.text ?? "");
  res.send("<p>User said: " + comment + "</p>");
});

In real projects, prefer a templating engine or a library like escape-html over hand-rolled escaping — it is easy to miss a character, and the order of the replacements above matters (escaping & first, before the entities you just introduced, is required to avoid double-escaping).

Best Practices

  • Validate on the server always, even if the client also validates — client-side checks are a UX nicety, not a security boundary, and are trivial to bypass.
  • Prefer allow-lists ("only these values / this pattern are acceptable") over deny-lists ("block these known-bad values") — deny-lists are always incomplete.
  • Use a schema library (zod, joi, express-validator) for anything beyond a couple of fields; hand-rolled checks drift out of sync with reality as endpoints grow.
  • Set a body size limit (express.json({ limit: "10kb" })) so validation itself can’t be used to exhaust memory.
  • Always use parameterized queries or an ORM’s query builder — never build SQL or NoSQL queries with string concatenation or template literals.
  • Escape output for the context you’re writing into: HTML escaping for HTML, URL encoding for URLs, and never reuse one escaping function for every sink.
  • Reject or strip __proto__, constructor, and prototype keys when merging untrusted objects, or use schema validation so unknown keys are dropped automatically.
  • For file paths built from user input, resolve the path and verify it stays inside the intended directory before touching the filesystem.
  • Keep regular expressions simple and anchored; avoid nested quantifiers like (a+)+ that can cause catastrophic backtracking (ReDoS) on attacker-controlled input.
  • Fail closed: when validation is uncertain or a library throws unexpectedly, reject the request rather than proceeding with unvalidated data.

Practice Exercises

Exercise 1: Write a zod schema for a "create post" endpoint with fields title (string, 3–120 chars), body (string, at least 10 chars), and tags (an array of 1–5 strings). Test it with a payload that has 0 tags and confirm you get a validation error.

Exercise 2: Write an Express middleware that validates a page query parameter is a positive integer (defaulting to 1 if missing), responding with 400 and a clear error message for values like -1, abc, or 3.5.

Exercise 3: The following handler is vulnerable to path traversal: app.get("/files/:name", (req, res) => res.sendFile(path.join(UPLOAD_DIR, req.params.name))). Figure out a name value that escapes UPLOAD_DIR, then rewrite the handler to reject it using path.resolve and a prefix check.

Summary

  • Validation rejects data that doesn’t match the expected shape, type, and range; sanitization transforms accepted data so it’s safe for a specific use (SQL, HTML, filesystem).
  • Never trust client-side validation alone — always re-validate on the server.
  • Schema libraries like zod combine validation, sanitization, and typed output in one declarative step, and scale much better than hand-rolled checks.
  • Parameterized queries prevent SQL injection; string concatenation in queries is never safe.
  • Context-aware escaping (not blacklists) prevents XSS; allow-lists beat deny-lists everywhere.
  • Guard object merges against __proto__ and similar keys to prevent prototype pollution.
  • Put validation in middleware so it runs before any business logic touches untrusted data, and always cap request body size.