Input Validation & Sanitization

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.

4 min read Level 2/5 #koa#security#validation
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

TechniqueGoalExample
ValidationReject data that doesn’t meet expectationsemail must be valid RFC-5322
SanitizationClean data before useTrim whitespace, normalise unicode
Escaping (output)Neutralise special chars before renderingHTML-encode < as &lt;

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 "&lt;script&gt;"

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 →