Security isn't optional — even at the early stage
Here's a pattern we see constantly: a startup builds fast, gets traction, lands their first enterprise prospect, and then fails the security review. Or worse — they get breached, and what would have been a one-day fix becomes a week-long incident response with legal implications.
The security measures in this guide aren't theoretical. They're the exact vulnerabilities we find in nearly every Node.js API audit we perform. Each one takes less than an hour to implement, and together they cover the most common attack vectors.
Let's go through them one by one, with code you can drop into your project today.
Install the essentials
Start by adding the packages we'll use throughout this guide:
npm install helmet express-rate-limit zod jsonwebtoken bcryptjs cors
npm install -D @types/jsonwebtoken @types/bcryptjs @types/cors @types/express
Helmet — security headers in one line
Helmet sets HTTP security headers that protect against a range of common attacks: XSS, clickjacking, MIME sniffing, and more. It's the single highest-ROI security package you can add.
// src/middleware/security-headers.ts
import helmet from "helmet";
import express from "express";
const app = express();
// Apply Helmet with sensible production defaults
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
frameSrc: ["'none'"],
upgradeInsecureRequests: [],
},
},
crossOriginEmbedderPolicy: true,
crossOriginOpenerPolicy: true,
crossOriginResourcePolicy: { policy: "same-origin" },
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
})
);
The Content Security Policy (CSP) is the most important header here. It tells the browser exactly which resources are allowed to load, which shuts down most XSS attacks even if an attacker finds an injection point. Adjust the directives to match your application's needs — if you use a CDN or external analytics, you'll need to add those domains.
Rate limiting — prevent abuse and brute force
Without rate limiting, your API is an open invitation for brute force attacks, credential stuffing, and DoS. Apply different limits for different endpoints based on their sensitivity.
// src/middleware/rate-limiter.ts
import rateLimit from "express-rate-limit";
import type { Request, Response, NextFunction } from "express";
// General API rate limit: 100 requests per 15 minutes per IP
export const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: {
error: "Too many requests, please try again later.",
retryAfter: 15,
},
keyGenerator: (req: Request): string => {
// Use X-Forwarded-For if behind a reverse proxy
return (req.headers["x-forwarded-for"] as string) || req.ip || "unknown";
},
});
// Strict limit for authentication endpoints: 5 attempts per 15 minutes
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
standardHeaders: true,
legacyHeaders: false,
message: {
error: "Too many login attempts. Please try again in 15 minutes.",
retryAfter: 15,
},
skipSuccessfulRequests: true,
});
// Very strict limit for password reset: 3 attempts per hour
export const passwordResetLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 3,
standardHeaders: true,
legacyHeaders: false,
message: {
error: "Too many password reset attempts. Please try again later.",
retryAfter: 60,
},
});
// src/app.ts — applying the limiters
import express from "express";
import { apiLimiter, authLimiter, passwordResetLimiter } from "./middleware/rate-limiter";
const app = express();
// Apply general limiter to all routes
app.use("/api/", apiLimiter);
// Apply stricter limiters to sensitive endpoints
app.use("/api/auth/login", authLimiter);
app.use("/api/auth/register", authLimiter);
app.use("/api/auth/reset-password", passwordResetLimiter);
The key insight is layered limits: a generous general limit that won't impact normal usage, and progressively stricter limits on sensitive endpoints. The skipSuccessfulRequests option on the auth limiter means successful logins don't count against the limit — only failed attempts do.
For production at scale, replace the default in-memory store with Redis so rate limits work correctly across multiple server instances:
npm install rate-limit-redis ioredis
Input validation with Zod — trust nothing from the client
Every piece of data that crosses your API boundary is untrusted. Validate it before it goes anywhere near your business logic or database. Zod makes this expressive and type-safe.
// src/validators/user.ts
import { z } from "zod";
export const createUserSchema = z.object({
email: z
.string()
.email("Invalid email address")
.max(255, "Email must be less than 255 characters")
.transform((val) => val.toLowerCase().trim()),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.max(72, "Password must be less than 72 characters") // bcrypt limit
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
"Password must contain at least one uppercase letter, one lowercase letter, and one number"
),
name: z
.string()
.min(1, "Name is required")
.max(100, "Name must be less than 100 characters")
.transform((val) => val.trim()),
});
export const updateUserSchema = z.object({
name: z
.string()
.min(1)
.max(100)
.transform((val) => val.trim())
.optional(),
email: z
.string()
.email()
.max(255)
.transform((val) => val.toLowerCase().trim())
.optional(),
});
// Reusable pagination schema
export const paginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sortBy: z.enum(["createdAt", "updatedAt", "name"]).default("createdAt"),
sortOrder: z.enum(["asc", "desc"]).default("desc"),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
export type PaginationInput = z.infer<typeof paginationSchema>;
// src/middleware/validate.ts
import { z, ZodError } from "zod";
import type { Request, Response, NextFunction } from "express";
export function validate(schema: z.ZodSchema) {
return (req: Request, res: Response, next: NextFunction): void => {
try {
req.body = schema.parse(req.body);
next();
} catch (error) {
if (error instanceof ZodError) {
res.status(400).json({
error: "Validation failed",
details: error.errors.map((err) => ({
field: err.path.join("."),
message: err.message,
})),
});
return;
}
next(error);
}
};
}
// Usage in routes:
// router.post("/users", validate(createUserSchema), createUser);
Zod schemas serve double duty: they validate at runtime and generate TypeScript types at compile time. The transform calls normalize input (trimming whitespace, lowercasing emails) so your business logic doesn't have to worry about it. And the password max length of 72 matches bcrypt's actual limit — anything beyond that is silently truncated, which could cause subtle bugs.
JWT authentication middleware
A stateless JWT authentication middleware that validates tokens, checks expiration, and attaches the user to the request:
// src/middleware/auth.ts
import jwt from "jsonwebtoken";
import type { Request, Response, NextFunction } from "express";
interface TokenPayload {
userId: string;
email: string;
role: string;
iat: number;
exp: number;
}
// Extend Express Request type
declare global {
namespace Express {
interface Request {
user?: TokenPayload;
}
}
}
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
throw new Error("JWT_SECRET environment variable is required");
}
export function authenticate(
req: Request,
res: Response,
next: NextFunction
): void {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
res.status(401).json({ error: "Authentication required" });
return;
}
const token = authHeader.split(" ")[1];
try {
const payload = jwt.verify(token, JWT_SECRET) as TokenPayload;
req.user = payload;
next();
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
res.status(401).json({ error: "Token expired" });
return;
}
if (error instanceof jwt.JsonWebTokenError) {
res.status(401).json({ error: "Invalid token" });
return;
}
next(error);
}
}
export function authorize(...allowedRoles: string[]) {
return (req: Request, res: Response, next: NextFunction): void => {
if (!req.user) {
res.status(401).json({ error: "Authentication required" });
return;
}
if (!allowedRoles.includes(req.user.role)) {
res.status(403).json({ error: "Insufficient permissions" });
return;
}
next();
};
}
// Token generation helper
export function generateTokens(user: {
id: string;
email: string;
role: string;
}) {
const accessToken = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: "15m" }
);
const refreshToken = jwt.sign(
{ userId: user.id, type: "refresh" },
JWT_SECRET,
{ expiresIn: "7d" }
);
return { accessToken, refreshToken };
}
// Usage in routes:
// router.get("/admin/users", authenticate, authorize("admin"), listUsers);
// router.get("/profile", authenticate, getProfile);
Key decisions: short-lived access tokens (15 minutes) limit the window of exposure if a token is compromised. Refresh tokens (7 days) let users stay logged in without re-entering credentials. The authorize middleware is separate from authenticate so you can compose them — some endpoints need authentication but not role-based access control.
In production, store refresh tokens in your database and implement a revocation mechanism. If a user changes their password or reports their account compromised, you need to be able to invalidate all existing sessions.
CORS configuration — control who can call your API
Misconfigured CORS is one of the most common issues we see. Either it's wide open (*) or it's blocking legitimate requests. Here's a proper configuration:
// src/middleware/cors-config.ts
import cors from "cors";
const ALLOWED_ORIGINS = [
"https://yourapp.com",
"https://www.yourapp.com",
"https://staging.yourapp.com",
];
// Add localhost origins in development
if (process.env.NODE_ENV === "development") {
ALLOWED_ORIGINS.push(
"http://localhost:3000",
"http://localhost:5173"
);
}
export const corsMiddleware = cors({
origin: (origin, callback) => {
// Allow requests with no origin (mobile apps, curl, server-to-server)
if (!origin) {
callback(null, true);
return;
}
if (ALLOWED_ORIGINS.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`Origin ${origin} not allowed by CORS`));
}
},
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "X-Request-ID"],
exposedHeaders: ["X-Total-Count", "X-Request-ID"],
maxAge: 86400, // Cache preflight for 24 hours
});
The origin callback gives you fine-grained control. Requests with no origin (server-to-server calls, mobile apps, curl) are allowed through — they're not subject to browser CORS rules anyway. The maxAge setting caches preflight responses for 24 hours, which reduces the number of OPTIONS requests your server handles.
SQL injection prevention — parameterized queries everywhere
SQL injection remains one of the most exploited vulnerabilities on the web. The fix is simple: never concatenate user input into SQL strings. Always use parameterized queries.
// BAD — vulnerable to SQL injection
// const result = await db.query(
// `SELECT * FROM users WHERE email = '${email}'`
// );
// GOOD — parameterized query with node-postgres (pg)
import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === "production"
? { rejectUnauthorized: true }
: false,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
});
// Simple parameterized query
async function getUserByEmail(email: string) {
const result = await pool.query(
"SELECT id, email, name, role, created_at FROM users WHERE email = $1",
[email]
);
return result.rows[0] || null;
}
// Parameterized query with multiple conditions
async function searchUsers(
name: string,
role: string,
limit: number,
offset: number
) {
const result = await pool.query(
`SELECT id, email, name, role, created_at
FROM users
WHERE name ILIKE $1
AND ($2::text IS NULL OR role = $2)
ORDER BY created_at DESC
LIMIT $3 OFFSET $4`,
[`%${name}%`, role || null, limit, offset]
);
return result.rows;
}
// Safe insert with RETURNING
async function createUser(
email: string,
hashedPassword: string,
name: string
) {
const result = await pool.query(
`INSERT INTO users (email, password_hash, name)
VALUES ($1, $2, $3)
RETURNING id, email, name, created_at`,
[email, hashedPassword, name]
);
return result.rows[0];
}
The $1, $2 placeholders are parameterized — the database driver handles escaping, so there's no way for user input to break out of the value context and become executable SQL. This applies to every database operation, including inserts, updates, and deletes.
If you're using an ORM like Prisma or Drizzle, parameterized queries are the default. But if you ever drop down to raw SQL (and you will for complex queries), always use parameterized placeholders.
Putting it all together
Here's how all these middleware pieces compose into a secure Express application:
// src/app.ts
import express from "express";
import helmet from "helmet";
import { corsMiddleware } from "./middleware/cors-config";
import { apiLimiter, authLimiter } from "./middleware/rate-limiter";
import { authenticate, authorize } from "./middleware/auth";
import { validate } from "./middleware/validate";
import { createUserSchema } from "./validators/user";
const app = express();
// Security middleware (order matters)
app.use(helmet());
app.use(corsMiddleware);
app.use(express.json({ limit: "10kb" })); // Limit body size
app.use(express.urlencoded({ extended: false }));
// Global rate limit
app.use("/api/", apiLimiter);
// Auth routes (stricter rate limit)
app.use("/api/auth/login", authLimiter);
app.use("/api/auth/register", authLimiter);
// Health check (no auth required)
app.get("/health", (_req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
// Public routes
app.post("/api/auth/register", validate(createUserSchema), registerHandler);
app.post("/api/auth/login", loginHandler);
// Protected routes
app.get("/api/profile", authenticate, getProfileHandler);
app.put("/api/profile", authenticate, validate(updateUserSchema), updateProfileHandler);
// Admin routes
app.get("/api/admin/users", authenticate, authorize("admin"), listUsersHandler);
// Global error handler
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error("Unhandled error:", err);
res.status(500).json({
error: process.env.NODE_ENV === "production"
? "Internal server error"
: err.message,
});
});
export default app;
Notice the express.json({ limit: "10kb" }) — this prevents large payload attacks. If your API accepts file uploads, handle those on specific routes with appropriate limits, not globally.
The bottom line
Security isn't a feature you add later — it's a property of how you build. Every item in this checklist takes less than an hour to implement, and together they protect against the vast majority of attacks we see in the wild.
The cost of implementing these measures on day one is trivial. The cost of not implementing them is unpredictable and potentially catastrophic. Don't learn this lesson the hard way.