Never Trust ctx.request.body — Validate, Sanitize, Escape
Input Validation & Sanitization
Every value that arrives in ctx.request.body, ctx.params, or ctx.query is untrusted. Validate shape and types with zod, sanitize strings, and escape output to stop injection and XSS at the source.
What you'll learn
- Validate request bodies with zod before processing them
- Distinguish validation (reject bad data) from sanitization (clean it)
- Escape dynamic values in HTML output and SQL queries to prevent injection
User input is the most common attack surface in a web application. SQL
injection, XSS, path traversal, and prototype pollution all start with data you
didn’t validate. The rule is simple: treat ctx.request.body, ctx.params,
and ctx.query as untrusted strings until proven otherwise.
Validation with zod
zod parses and validates a value against a schema, returning a typed result.
Call it at the top of every route handler.
npm install zod import { z } from "zod";
const RegisterSchema = z.object({
email: z.string().email().max(254).toLowerCase(),
password: z.string().min(8).max(72), // 72 = bcrypt input limit
username: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_-]+$/),
});
router.post("/auth/register", async (ctx) => {
const result = RegisterSchema.safeParse(ctx.request.body);
if (!result.success) {
ctx.status = 422;
ctx.body = { errors: result.error.flatten().fieldErrors };
return;
}
const { email, password, username } = result.data; // fully typed and safe
// proceed with registration...
}); Validation vs Sanitization
| Technique | Goal | Example |
|---|---|---|
| Validation | Reject data that doesn’t meet expectations | email must be valid RFC-5322 |
| Sanitization | Clean data before use | Trim whitespace, normalise unicode |
| Escaping (output) | Neutralise special chars before rendering | HTML-encode < as < |
Validation and sanitization are complementary, not alternatives.
Preventing SQL Injection
Always use parameterised queries or an ORM. Never interpolate user input into SQL strings.
// UNSAFE — never do this
const rows = await db.query(`SELECT * FROM users WHERE email = '${email}'`);
// SAFE — parameterised query (postgres example)
const rows = await db.query("SELECT * FROM users WHERE email = $1", [email]); Preventing XSS in HTML Output
If your Koa app renders HTML (e.g., with a template engine), escape dynamic values before inserting them into the DOM.
// Using the 'he' library for HTML entity encoding
import he from "he";
const safeUsername = he.encode(user.username);
// "<script>" becomes "<script>" For JSON API responses, XSS via ctx.body = object is not a concern — Koa
sets Content-Type: application/json, and browsers won’t execute JSON as HTML.
Your CSP (koa-helmet) is the backstop for client-side rendering.
Timing-Safe Comparison
When comparing secrets (tokens, API keys), use crypto.timingSafeEqual to
prevent timing attacks. bcrypt’s compare already does this for passwords.
import { timingSafeEqual, createHash } from "node:crypto";
function safeCompare(a, b) {
const hashA = createHash("sha256").update(a).digest();
const hashB = createHash("sha256").update(b).digest();
return hashA.length === hashB.length && timingSafeEqual(hashA, hashB);
} Up Next
Connect your Koa app to a database with a query builder or ORM, and learn how to structure the data layer cleanly.
Database Integration →