Back

Safe User Input Handling in Node.js

Safe User Input Handling in Node.js

Every Node.js API receives data it cannot fully trust. JSON bodies, query strings, form fields, and file uploads all arrive from the outside world, and any of them can carry malicious payloads. SQL injection, NoSQL injection, command injection, prototype pollution — these attacks share a common entry point: unvalidated user input reaching business logic, a database, or a shell command. This article walks through the practical patterns that stop them.

Key Takeaways

  • Always validate incoming data against a strict schema before it reaches your application logic — define what is permitted and reject everything else.
  • Use parameterized queries for SQL and enforce strict data types for NoSQL to prevent injection attacks.
  • Avoid child_process.exec() for system commands; use execFile() or spawn() with explicit argument arrays instead.
  • Never spread raw request bodies onto models; pick only the fields you expect to prevent mass assignment and prototype pollution.
  • Set body size limits and validate file uploads at the parser level to guard against denial-of-service attacks.

Validate First, Process Second

The single most effective rule in secure request handling in Node.js APIs is: never let raw request data touch your application logic. Validate the shape and type of every incoming value before you use it.

Schema-based validation is the cleanest approach. Libraries like Zod and Joi let you declare exactly what a request should look like and reject anything that doesn’t match.

import { z } from "zod"

const RegisterSchema = z.object({
  username: z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/),
  email: z.string().email(),
  password: z.string().min(10),
})

app.post("/register", (req, res) => {
  const result = RegisterSchema.safeParse(req.body)
  if (!result.success) {
    return res.status(400).json({ errors: result.error.flatten() })
  }
  // result.data is now safe to use
})

This is an allowlist approach: you define what is permitted, and everything else is rejected. It is far more reliable than trying to denylist dangerous characters. The same principle is emphasized in the OWASP Input Validation Cheat Sheet.


Prevent Injection Attacks

SQL and NoSQL Injection

Never concatenate user input into queries. Use parameterized statements for SQL:

// ❌ Vulnerable
const query = `SELECT * FROM users WHERE email = '${req.body.email}'`

// ✅ Secure
const result = await db.query("SELECT * FROM users WHERE email = $1", [email])

NoSQL databases have their own injection risk. MongoDB operators like $gt can be injected through JSON bodies if you pass objects directly:

// ❌ Vulnerable — req.body.username could be { $gt: "" }
User.find({ username: req.body.username })

// ✅ Secure — enforce the expected type first
User.find({ username: String(req.body.username) })

In practice, schema validation should ensure the field is a string (for example with z.string()) before it reaches the query layer.

Command Injection

If your API calls system commands, avoid child_process.exec() entirely — it passes arguments through a shell and is trivially exploitable. Use execFile() or spawn() with explicit argument arrays instead:

import { execFile } from "child_process"

// ✅ Arguments are passed directly, not interpreted by a shell
execFile("convert", [userSuppliedFilename, outputPath], (err, stdout) => {
  // handle result
})

Still validate and allowlist the argument values. If the operation is high-risk, run it inside a sandboxed container.


Avoid Mass Assignment and Prototype Pollution

Mass assignment happens when you spread a request body directly onto a model:

// ❌ A user could send { role: "admin" }
const user = await User.create({ ...req.body })

// ✅ Pick only the fields you expect
const user = await User.create({
  username: result.data.username,
  email: result.data.email,
})

Prototype pollution is subtler. Merging untrusted objects can let an attacker inject properties like __proto__ or constructor.prototype that affect every object in your process. Avoid deep-merge utilities on raw request data. If you must merge, validate the input first and create intermediate objects with Object.create(null) so they do not inherit from the default prototype chain. The OWASP Prototype Pollution Prevention Cheat Sheet provides further guidance.


Limit Request Size and Structure

Large or deeply nested payloads can cause denial-of-service issues. Set body size limits at the parser level:

app.use(express.json({ limit: "50kb" }))

For file uploads, use multer with explicit fileSize and files limits, and validate MIME types — never trust the file extension alone.


Conclusion

Safe user input handling in Node.js comes down to one principle: define what valid input looks like, enforce it at the boundary, and never let anything else through. Schema validation, parameterized queries, explicit argument passing, and strict field selection cover the majority of real-world attack surfaces. Apply these patterns consistently at the request layer, before data reaches any downstream logic, and most injection-class vulnerabilities simply have nowhere to land.

FAQs

Schema validation is a strong first line of defense because it rejects malformed input before it reaches your logic. However, it should be combined with parameterized queries and safe command execution. Validation ensures data has the right shape and type, but the downstream code must also handle that data safely to cover edge cases validation alone might miss.

Both libraries are mature and widely used. Zod offers first-class TypeScript support with strong type inference, making it a natural fit for TypeScript projects. Joi has a longer track record in the Express ecosystem and offers a rich, chainable API. Choose based on your project's language preferences and existing dependencies.

Avoid running deep-merge functions on raw request data. Validate input with a strict schema first so only known fields pass through. When merging is necessary, create intermediate objects with Object.create(null) to produce objects without a prototype chain. Libraries like lodash.merge have had prototype pollution vulnerabilities in the past, so always keep dependencies updated.

There is no universal answer, but 100kb to 1MB covers most JSON API use cases. Start with a conservative limit like 100kb and increase it only if legitimate requests require more. For file uploads, configure separate limits through your multipart parser. The goal is to reject abnormally large payloads early before they consume memory or processing time.

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay