An Express route should not trust a request just because its body is valid JSON. It should check that the input has the expected shape, types, and size before the application uses it. This guide shows schema validation with Zod, safe error responses, and why input validation and output encoding solve different problems.
Key Takeaways
- Validate request bodies against an explicit schema; do not rely on client-side checks.
- Enforce both request-size limits and per-field length limits.
- Check types without silently converting unexpected values.
- Return useful, generic errors without echoing submitted values or internal details.
- Validation and sanitization do not replace parameterized database queries or context-specific output encoding.
Validate the request at the API boundary
JSON parsing checks syntax, not whether a request makes sense for your application. A parsed body might be an object with missing fields, unexpected properties, or values of the wrong type.
A schema makes the endpoint's accepted input explicit. This example uses Zod to validate a user-creation request. Install the dependencies with npm install express zod. Save the following as server.js in a Node.js project configured for CommonJS, then run it with node server.js.
const express = require("express");
const { z } = require("zod");
const app = express();
// Limits the JSON request body by bytes before route validation runs.
app.use(express.json({ limit: "16kb" }));
const createUserSchema = z
.object({
email: z.string().trim().email().max(254),
displayName: z.string().trim().min(1).max(80),
age: z.number().int().min(13).max(120).optional(),
})
.strict();
app.post("/users", (req, res) => {
if (!req.is("application/json")) {
return res.status(415).json({ error: "Expected application/json" });
}
const result = createUserSchema.safeParse(req.body);
if (!result.success) {
const acceptedFields = new Set(["email", "displayName", "age"]);
const fields = [
...new Set(
result.error.issues
.map((issue) => issue.path[0])
.filter((field) => typeof field === "string" && acceptedFields.has(field))
),
];
return res.status(400).json({
error: "Invalid request body",
...(fields.length > 0 ? { fields } : {}),
});
}
// Use only the validated data from here on.
const userInput = result.data;
return res.status(201).json({
user: {
email: userInput.email,
displayName: userInput.displayName,
...(userInput.age !== undefined ? { age: userInput.age } : {}),
},
});
});
// Keep parser errors and unexpected server errors from becoming HTML or
// exposing stack traces through the API response.
app.use((err, req, res, next) => {
if (err && err.type === "entity.parse.failed") {
return res.status(400).json({ error: "Malformed JSON" });
}
if (err && err.type === "entity.too.large") {
return res.status(413).json({ error: "Request body too large" });
}
console.error("Unhandled API error", {
method: req.method,
path: req.path,
});
return res.status(500).json({ error: "Internal server error" });
});
app.listen(3000, () => {
console.log("API listening on port 3000");
});
The schema requires an email string and a non-empty display name, limits their lengths, and accepts age only as an integer in the specified range. Because the schema does not coerce values, a JSON string such as "25" is not accepted as a number. .strict() rejects unexpected object keys instead of silently accepting them. Choose that behavior deliberately: some APIs may instead choose to ignore unknown fields, but they should still validate the fields they use.
express.json({ limit: "16kb" }) limits the request body size in bytes; the schema's .max() checks individual strings. Both controls matter. A field limit does not stop an oversized body containing many fields, and a body-size limit does not enforce business rules for individual values.
Return errors that help without leaking data
The example responds with HTTP 400 when the body fails validation, 415 when the request is not JSON, and 413 when the body exceeds the parser's limit. The validation response names only recognized fields. It does not return the rejected values, a stack trace, or database details.
Choose error details based on your API's needs. Field names or stable error codes can help clients correct requests; avoid including secrets, raw input, internal exception messages, or implementation details. Also avoid assuming every client is trustworthy just because it receives a detailed response.
Malformed JSON is different from valid JSON with an invalid shape. The parser error handler gives malformed JSON a controlled response. The final error handler provides a generic response for unexpected failures. In a real service, send useful diagnostics to access-controlled logs and avoid logging request bodies by default, since they can contain personal or secret data.
Validation is not sanitization or output encoding
These terms describe different controls:
- Validation decides whether a value meets the endpoint's rules, such as being a string within an allowed length.
- Normalization applies a defined transformation, such as trimming surrounding whitespace. The example trims the email and display name before using them.
- Sanitization modifies input to remove or neutralize content according to a particular purpose. It is not a general-purpose security guarantee.
- Output encoding makes data safe for the context where it is displayed, such as HTML text or an HTML attribute.
Do not strip characters from every input in an attempt to prevent injection. That can corrupt legitimate data and still miss vulnerabilities. A valid string can still be unsafe to concatenate into SQL, and a value that is safe in a database is not necessarily safe to insert into HTML.
For SQL, use parameterized queries or your database library's safe parameter-binding API; do not build a query by concatenating user values. For HTML, use a template engine's context-aware escaping or an appropriate encoding mechanism. In browser-side code, prefer text APIs such as textContent over inserting untrusted strings as HTML. If rich HTML is a product requirement, use a well-maintained HTML sanitizer configured for that specific use case—and still encode correctly when rendering in other contexts.
Adapt the schema to the endpoint
A schema should express business rules, not just data types. For example, a username might need an allowed character set, while a quantity might need a minimum and maximum tied to the product. Validate identifiers and enumerated values against the values the application actually supports.
Treat different parts of a request separately. Path parameters, query parameters, headers, and JSON bodies have different parsing and type behavior. Query and path parameters commonly arrive as strings, so parse them deliberately and reject values outside the endpoint's allowed range. Do not assume validating the body validates the entire request.
Validation also does not establish authorization. A syntactically valid account ID does not prove that the caller may access that account. Check authentication and authorization independently, close to the operation that uses the resource.
Common Mistakes
- Checking only that the request is JSON: Valid JSON can still have missing fields, extra properties, or incorrect types.
- Relying on frontend validation: Clients can bypass browser checks and send requests directly.
- Using coercion without a deliberate policy: Converting strings to numbers can hide malformed or ambiguous input. If coercion is appropriate, define and test exactly what is accepted.
- Setting only a body-size limit: A small body can still contain invalid values, and a field limit does not cap the total request size.
- Returning raw validation or server errors: Responses that include submitted data, exception messages, or stack traces can expose information unnecessarily.
- Treating sanitization as a universal defense: Escaping, parameterization, and validation address different contexts and risks.
- Assuming valid input is authorized: Validation checks shape and constraints; authorization checks permission.
Best Practices
- Validate every untrusted input at the server boundary, including query and path parameters.
- Define explicit allowed types, required fields, length limits, and business constraints.
- Set parser limits appropriate to the endpoint and reject unsupported content types.
- Use validated, normalized data after parsing rather than switching back to the raw request body.
- Return stable, minimal error responses and keep sensitive diagnostics out of client responses.
- Use parameterized database operations and context-specific output encoding even when inputs have passed validation.
- Test valid, missing, oversized, malformed, wrong-type, and unexpected-field cases.
Conclusion
A secure Express API uses schema validation to enforce what an endpoint accepts, limits to control request size, and safe errors to avoid unnecessary disclosure. Keep those controls distinct from authorization, database parameterization, and output encoding: no single sanitization step replaces them.
References
- Express: Security best practices — Express guidance for securing applications.
- OWASP: Input Validation Cheat Sheet — principles for validating untrusted input.
- OWASP: SQL Injection Prevention Cheat Sheet — guidance on preventing SQL injection.
- OWASP: Cross Site Scripting Prevention Cheat Sheet — context-aware defenses against XSS.
