Building a Node.js API is straightforward — but shipping one that’s actually secure requires deliberate effort. In 2026, API attacks remain one of the top vectors for data breaches, with injection, broken authentication, and misconfigured CORS topping the OWASP API Security Top 10. This guide walks you through eight practical, code-level security practices every Node.js developer should implement before going to production.
1. Validate and Sanitize All Input with Zod
Never trust user input. Use a schema validation library like Zod to enforce strict types at the boundary of your API.
import { z } from 'zod';
import express from 'express';
const UserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
age: z.number().int().min(13).max(120),
});
const app = express();
app.use(express.json());
app.post('/api/users', (req, res) => {
const result = UserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten() });
}
// result.data is now fully typed and safe
createUser(result.data);
res.status(201).json({ message: 'User created' });
});This eliminates entire classes of injection attacks by rejecting malformed data before it reaches your business logic.
2. Use Helmet.js for HTTP Security Headers
One line of middleware adds a dozen security headers including Content-Security-Policy, X-Content-Type-Options, and Strict-Transport-Security.
import helmet from 'helmet';
app.use(helmet());
// Customize CSP for your needs
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
},
})
);3. Implement Rate Limiting
Brute-force attacks and API abuse are trivially easy without rate limiting. Use express-rate-limit with a stricter limit on auth endpoints.
import rateLimit from 'express-rate-limit';
// General API limit
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, try again later.' },
});
// Strict limit for login
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
skipSuccessfulRequests: true,
});
app.use('/api/', apiLimiter);
app.use('/api/auth/login', authLimiter);4. Secure JWT Authentication Properly
JWTs are widely used but commonly misconfigured. Here’s a secure implementation:
import jwt from 'jsonwebtoken';
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET; // 256-bit random
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
function generateTokens(userId) {
const accessToken = jwt.sign({ sub: userId }, ACCESS_SECRET, {
algorithm: 'HS256',
expiresIn: '15m', // Short-lived access token
});
const refreshToken = jwt.sign({ sub: userId }, REFRESH_SECRET, {
algorithm: 'HS256',
expiresIn: '7d',
});
return { accessToken, refreshToken };
}
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
if (!token) return res.sendStatus(401);
try {
// Always specify algorithms to prevent "none" algorithm attack
const payload = jwt.verify(token, ACCESS_SECRET, { algorithms: ['HS256'] });
req.userId = payload.sub;
next();
} catch {
res.sendStatus(403);
}
}Key rules: Always set short expiry on access tokens, always specify the algorithm explicitly, and store secrets in environment variables — never in code.
5. Prevent NoSQL Injection in MongoDB
If you use MongoDB, query operator injection is a real threat. An attacker can send {"email": {"$gt": ""}} to bypass filters.
// BAD — vulnerable to operator injection
const user = await User.findOne({ email: req.body.email });
// GOOD — use express-mongo-sanitize middleware
import mongoSanitize from 'express-mongo-sanitize';
app.use(mongoSanitize()); // Strips $ and . from req.body/query/params
// ALSO GOOD — explicitly cast to string
const user = await User.findOne({ email: String(req.body.email) });Combined with Zod validation from step 1, this creates a strong defense-in-depth approach.
6. Configure CORS Correctly
Wildcard CORS (*) in production is a security risk. Be explicit:
import cors from 'cors';
const allowedOrigins = [
'https://myapp.com',
'https://admin.myapp.com',
];
app.use(cors({
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
}));7. Handle Errors Without Leaking Information
Stack traces and internal error messages in API responses are a goldmine for attackers. Use a global error handler:
// Custom error class
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
}
}
// Global error handler — MUST have 4 parameters
app.use((err, req, res, next) => {
if (err.isOperational) {
return res.status(err.statusCode).json({ error: err.message });
}
// Log the real error internally
console.error('Unexpected error:', err);
// Send generic message to client
res.status(500).json({ error: 'Something went wrong' });
});
// Usage
app.get('/api/users/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) throw new AppError('User not found', 404);
res.json(user);
} catch (err) {
next(err);
}
});8. Audit Dependencies and Keep Them Updated
Supply chain attacks are rising. Make dependency auditing part of your workflow:
# Check for known vulnerabilities
npm audit
# Auto-fix what's safe to fix
npm audit fix
# Use a lockfile and verify integrity
npm ci --ignore-scripts # in CI/CD
# Pin exact versions for critical deps
npm install express@4.21.2 --save-exactAdd npm audit to your CI pipeline so vulnerable packages block deploys. Consider tools like Snyk or Socket.dev for deeper supply chain analysis.
Putting It All Together
Here’s what a secure Express app setup looks like with all these practices combined:
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
import mongoSanitize from 'express-mongo-sanitize';
const app = express();
// Security middleware stack
app.use(helmet());
app.use(cors({ origin: allowedOrigins, credentials: true }));
app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }));
app.use(express.json({ limit: '10kb' })); // Limit body size
app.use(mongoSanitize());
// Routes with validation + auth
app.post('/api/users', authenticateToken, validateBody(UserSchema), createUser);
// Global error handler
app.use(globalErrorHandler);
app.listen(3000);Notice the express.json({ limit: '10kb' }) — this prevents large payload denial-of-service attacks, another simple but often-missed hardening step.
Final Thoughts
Security isn’t a feature you add at the end — it’s a practice you bake into every endpoint from day one. These eight techniques address the most common real-world attack vectors against Node.js APIs. Start with input validation and helmet, then layer in the rest. Your future self (and your users’ data) will thank you.

Leave a Reply