Skip to main content

Validation & Security

Never trust client input. In this lesson we'll validate request data with Zod, sanitize inputs, secure HTTP headers with Helmet, and implement JWT authentication.

Input Validation with Zod

Zod is a TypeScript-first schema validation library that infers types from your schemas automatically.

npm install zod

Defining Schemas

// src/schemas/user.ts
import { z } from "zod";

export const createUserSchema = z.object({
  name: z
    .string()
    .min(2, "Name must be at least 2 characters")
    .max(100, "Name must be under 100 characters")
    .trim(),
  email: z
    .string()
    .email("Invalid email address")
    .toLowerCase(),
  password: z
    .string()
    .min(8, "Password must be at least 8 characters")
    .regex(/[A-Z]/, "Password must contain an uppercase letter")
    .regex(/[0-9]/, "Password must contain a number"),
  role: z.enum(["user", "admin"]).default("user"),
});

export const updateUserSchema = createUserSchema.partial().omit({
  password: true,
});

// Infer TypeScript types directly from the schema
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;

Validation Middleware

Create a reusable middleware that validates the request body against any Zod schema:

// src/middleware/validate.ts
import type { Request, Response, NextFunction } from "express";
import { ZodSchema, ZodError } from "zod";

export function validate(schema: ZodSchema) {
  return (req: Request, res: Response, next: NextFunction) => {
    try {
      req.body = schema.parse(req.body);
      next();
    } catch (err) {
      if (err instanceof ZodError) {
        const errors = err.errors.map((e) => ({
          field: e.path.join("."),
          message: e.message,
        }));
        res.status(422).json({
          error: {
            code: "VALIDATION_ERROR",
            message: "Invalid input",
            details: errors,
          },
        });
        return;
      }
      next(err);
    }
  };
}

Using Validation in Routes

// src/routes/users.ts
import { Router } from "express";
import { validate } from "../middleware/validate.js";
import { createUserSchema, updateUserSchema } from "../schemas/user.js";

const router = Router();

router.post("/", validate(createUserSchema), (req, res) => {
  // req.body is now typed and validated
  const { name, email, password, role } = req.body;
  // ... create user
  res.status(201).json({ data: { name, email, role } });
});

router.patch("/:id", validate(updateUserSchema), (req, res) => {
  // Only validated fields are present
  // ... update user
  res.json({ data: req.body });
});

export default router;

Validating Query Parameters and Params

You can validate more than just the body:

// src/schemas/pagination.ts
import { z } from "zod";

export const paginationSchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  sort: z.enum(["createdAt", "name", "email"]).default("createdAt"),
  order: z.enum(["asc", "desc"]).default("desc"),
});

// Validate query params
export function validateQuery(schema: ZodSchema) {
  return (req: Request, res: Response, next: NextFunction) => {
    try {
      req.query = schema.parse(req.query);
      next();
    } catch (err) {
      if (err instanceof ZodError) {
        res.status(400).json({
          error: { code: "INVALID_QUERY", message: err.errors[0].message },
        });
        return;
      }
      next(err);
    }
  };
}

Input Sanitization

Even after validation, sanitize data to prevent injection attacks:

// Zod handles basic sanitization via transforms
const commentSchema = z.object({
  text: z
    .string()
    .max(1000)
    .trim()
    .transform((val) => {
      // Strip HTML tags to prevent XSS
      return val.replace(/<[^>]*>/g, "");
    }),
});

// For SQL — always use parameterized queries, never string interpolation
// Bad:  db.query(`SELECT * FROM users WHERE id = ${id}`)
// Good: db.query("SELECT * FROM users WHERE id = $1", [id])

Securing HTTP Headers with Helmet

Helmet sets security-related HTTP headers to protect against common attacks:

npm install helmet
import helmet from "helmet";

// Apply all default security headers
app.use(helmet());

// Or configure individual headers
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'"],
      },
    },
    hsts: {
      maxAge: 31536000, // 1 year
      includeSubDomains: true,
    },
  })
);

Helmet sets headers like X-Content-Type-Options, Strict-Transport-Security, X-Frame-Options, and more.

JWT Authentication

JSON Web Tokens provide stateless authentication for your API.

npm install jsonwebtoken bcryptjs
npm install -D @types/jsonwebtoken @types/bcryptjs

Auth Utilities

// src/utils/auth.ts
import jwt from "jsonwebtoken";
import bcrypt from "bcryptjs";

const JWT_SECRET = process.env.JWT_SECRET!;
const JWT_EXPIRES_IN = "7d";

export function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, 12);
}

export function verifyPassword(
  password: string,
  hash: string
): Promise<boolean> {
  return bcrypt.compare(password, hash);
}

export function generateToken(userId: string, role: string): string {
  return jwt.sign({ sub: userId, role }, JWT_SECRET, {
    expiresIn: JWT_EXPIRES_IN,
  });
}

export function verifyToken(token: string) {
  return jwt.verify(token, JWT_SECRET) as { sub: string; role: string };
}

Auth Middleware

// src/middleware/auth.ts
import type { Request, Response, NextFunction } from "express";
import { verifyToken } from "../utils/auth.js";
import { AppError } from "../utils/app-error.js";

// Extend Express Request type
declare global {
  namespace Express {
    interface Request {
      user?: { id: string; role: string };
    }
  }
}

export function authenticate(req: Request, _res: Response, next: NextFunction) {
  const header = req.headers.authorization;
  if (!header?.startsWith("Bearer ")) {
    throw new AppError(401, "UNAUTHORIZED", "Missing or invalid token");
  }

  try {
    const token = header.slice(7);
    const payload = verifyToken(token);
    req.user = { id: payload.sub, role: payload.role };
    next();
  } catch {
    throw new AppError(401, "UNAUTHORIZED", "Invalid or expired token");
  }
}

export function authorize(...roles: string[]) {
  return (req: Request, _res: Response, next: NextFunction) => {
    if (!req.user || !roles.includes(req.user.role)) {
      throw new AppError(403, "FORBIDDEN", "Insufficient permissions");
    }
    next();
  };
}

Auth Routes

// src/routes/auth.ts
import { Router } from "express";
import { validate } from "../middleware/validate.js";
import { createUserSchema } from "../schemas/user.js";
import { hashPassword, verifyPassword, generateToken } from "../utils/auth.js";

const router = Router();

router.post("/register", validate(createUserSchema), async (req, res) => {
  const { name, email, password } = req.body;
  const hashedPassword = await hashPassword(password);
  // Save user to database with hashedPassword
  const user = { id: "1", name, email, role: "user" };
  const token = generateToken(user.id, user.role);
  res.status(201).json({ data: { user, token } });
});

router.post("/login", async (req, res) => {
  const { email, password } = req.body;
  // Find user by email in database
  const user = { id: "1", email, role: "user", passwordHash: "..." };
  const valid = await verifyPassword(password, user.passwordHash);
  if (!valid) {
    res.status(401).json({ error: { message: "Invalid credentials" } });
    return;
  }
  const token = generateToken(user.id, user.role);
  res.json({ data: { token } });
});

export default router;

Protecting Routes

import { authenticate, authorize } from "./middleware/auth.js";

// All user routes require authentication
app.use("/api/users", authenticate, usersRouter);

// Admin-only route
app.delete("/api/users/:id", authenticate, authorize("admin"), (req, res) => {
  // Only admins can delete users
  res.status(204).send();
});