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 zodDefining 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 helmetimport 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/bcryptjsAuth 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();
});