Securing Node.js Applications
A fresh Express app does almost nothing to protect you. There's no CSRF protection, no security headers, no rate limit on the login route, and it happily announces X-Powered-By: Express to anyone who asks. Frameworks like Laravel or Rails ship with a lot of that switched on. In Node, every one of those decisions is yours, and the ones you forget about are the ones that bite.
This post walks through what I'd put in place before a Node.js API goes to production: input validation, password and token handling, injection and CSRF defenses, Helmet, rate limiting, dependency and secrets hygiene, and error handling that doesn't leak your internals.
Web application attacks show up year after year as one of the top breach patterns in Verizon's Data Breach Investigations Report. A typical Node.js backend holds auth tokens, personal data and sometimes payment details, so it's exactly the kind of thing those attacks go after. Treat security work as part of shipping the feature, not a separate ticket for later.
What You're Actually Defending Against
Most of the risks are the usual OWASP Top 10 suspects. On top of those, Node adds a few of its own, mostly because of JavaScript's dynamic objects and how deep the npm dependency tree goes.
The Most Dangerous Attack Vectors
| Attack Type | Risk Level | Primary Defense |
|---|---|---|
| SQL / NoSQL Injection | Critical | Parameterized queries, input validation |
| Cross-Site Scripting (XSS) | High | Output encoding, Content Security Policy |
| Broken Authentication | Critical | bcrypt, JWT best practices, MFA |
| CSRF Attacks | High | CSRF tokens, SameSite cookies |
| Prototype Pollution | High | Input sanitization, Object.create(null) |
| Dependency Vulnerabilities | Medium to Critical | npm audit, Snyk, automated updates |
| Secrets Exposure | Critical | Environment variables, secrets management |
| Rate Limiting Bypass | Medium | express-rate-limit, IP-based throttling |
There's one more Node-specific problem: everything runs on a single-threaded event loop. One unhandled promise rejection can crash the process, and one badly written regex fed a nasty input (ReDoS, regular expression denial of service) can block every other request while it backtracks.
Input Validation and Sanitization
Start with the boring rule: never trust user input. Request bodies, query parameters, headers and cookies all come from the client, and all of them should be checked against a strict schema before they reach your business logic or database.
Validating with express-validator
express-validator wraps validator.js in chainable middleware, so the rules sit right next to the route they protect.
const { body, validationResult } = require('express-validator');
const userValidation = {
register: [
body('email')
.isEmail().withMessage('Invalid email format')
.normalizeEmail()
.isLength({ max: 255 }),
body('password')
.isLength({ min: 8 }).withMessage('Password must be at least 8 characters')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/)
.withMessage('Password must contain uppercase, lowercase, number, and special char'),
body('username')
.trim()
.isAlphanumeric().withMessage('Username can only contain letters and numbers')
.isLength({ min: 3, max: 30 }),
],
};
// Middleware to check results and respond with errors
function validate(req, res, next) {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
errors: errors.array().map(e => ({ field: e.path, message: e.msg }))
});
}
next();
}
module.exports = { userValidation, validate };
const { userValidation, validate } = require('../middleware/validate');
router.post('/register',
userValidation.register,
validate,
authController.register // Invalid data never reaches the controller
);
Schema Validation with Joi
For nested request bodies I prefer Joi. You describe the whole shape once, including limits like "at most 50 items", and validate it in one go:
const Joi = require('joi');
const createOrderSchema = Joi.object({
items: Joi.array().items(
Joi.object({
productId: Joi.string().uuid().required(),
quantity: Joi.number().integer().min(1).max(100).required(),
})
).min(1).max(50).required(),
shippingAddress: Joi.object({
street: Joi.string().trim().max(200).required(),
city: Joi.string().trim().max(100).required(),
postalCode: Joi.string().pattern(/^[A-Z0-9]{3,10}$/i).required(),
country: Joi.string().length(2).uppercase().required(),
}).required(),
couponCode: Joi.string().alphanum().max(20).optional(),
});
// Middleware factory
function joiValidate(schema) {
return (req, res, next) => {
const { error, value } = schema.validate(req.body, {
abortEarly: false, // Return all errors, not just first
stripUnknown: true, // Remove fields not in schema (prevents mass assignment)
convert: true, // Type coercion (string to number where declared)
});
if (error) {
return res.status(400).json({
errors: error.details.map(d => ({ field: d.path.join('.'), message: d.message }))
});
}
req.body = value; // Use the cleaned, validated value
next();
};
}
Configure Joi with stripUnknown: true. It drops any field the client sends that isn't in your schema. Without it, someone can add isAdmin: true to a request body, and if that body goes straight into your ORM's create or update call, you've got a mass assignment bug.
Securing Authentication and Sessions
The auth bugs I run into are rarely exotic. It's usually passwords hashed with the wrong algorithm, tokens stored somewhere JavaScript can read them, or a login route with no brute-force protection at all.
Password Hashing with bcrypt
Don't store passwords in plaintext or hash them with MD5 or SHA-1. Those algorithms are built to be fast, and fast is exactly what an attacker with a GPU wants. bcrypt is deliberately slow and salts every password for you.
const bcrypt = require('bcrypt');
// Cost factor of 12 = ~300ms per hash on modern hardware
// Increase over time as hardware improves
const SALT_ROUNDS = 12;
async function hashPassword(plaintext) {
return bcrypt.hash(plaintext, SALT_ROUNDS);
}
async function verifyPassword(plaintext, hash) {
// bcrypt.compare is constant-time, so it's safe against timing attacks
return bcrypt.compare(plaintext, hash);
}
// During registration
async function registerUser(email, password) {
const existing = await User.findOne({ email });
if (existing) {
// Always hash even if the user exists: prevents timing-based email enumeration
await bcrypt.hash(password, SALT_ROUNDS);
throw new Error('Registration failed');
}
const hashedPassword = await hashPassword(password);
return User.create({ email, password: hashedPassword });
}
// During login, always return the same generic error
async function loginUser(email, password) {
const user = await User.findOne({ email });
const hash = user?.password || '$2b$12$invalidHashForTimingAttackPrevention';
const isValid = await bcrypt.compare(password, hash);
if (!user || !isValid) {
throw new Error('Invalid credentials'); // Never distinguish "wrong email" from "wrong password"
}
return user;
}
bcrypt only looks at the first 72 bytes of the input. Two long passphrases that share the same first 72 bytes will hash to the same thing, and multi-byte characters eat into that limit faster than you'd expect. Put a sensible max length on the password field (validation is a good place for it), or switch to Argon2id if you need to support very long passphrases.
JWT Security Best Practices
JWTs get implemented badly all the time. The usual mistakes: keeping them in localStorage where any XSS can read them, signing with a short secret, and setting expiry in days when it should be minutes.
const jwt = require('jsonwebtoken');
const ACCESS_TOKEN_SECRET = process.env.JWT_ACCESS_SECRET; // Min 32 chars
const REFRESH_TOKEN_SECRET = process.env.JWT_REFRESH_SECRET; // Different key!
function generateTokens(userId, roles = []) {
// Short-lived access token stored in memory on the client
const accessToken = jwt.sign(
{ sub: userId, roles, type: 'access' },
ACCESS_TOKEN_SECRET,
{ expiresIn: '15m', algorithm: 'HS256' }
);
// Longer refresh token stored in httpOnly cookie, never localStorage
const refreshToken = jwt.sign(
{ sub: userId, type: 'refresh' },
REFRESH_TOKEN_SECRET,
{ expiresIn: '7d', algorithm: 'HS256' }
);
return { accessToken, refreshToken };
}
// Auth middleware
function authenticateJWT(req, res, next) {
const authHeader = req.headers.authorization;
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
if (!token) return res.status(401).json({ error: 'Authentication required' });
try {
req.user = jwt.verify(token, ACCESS_TOKEN_SECRET, {
algorithms: ['HS256'], // Whitelist prevents algorithm confusion attacks
});
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
res.status(403).json({ error: 'Invalid token' });
}
}
module.exports = { generateTokens, authenticateJWT };
Secure Cookie Configuration
Put refresh tokens in httpOnly cookies, not localStorage. JavaScript can't read them there, so an XSS bug can't simply grab the token and send it off:
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // Inaccessible to document.cookie / XSS
secure: true, // HTTPS only, never sent over plain HTTP
sameSite: 'strict', // Blocks cross-site CSRF requests
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in ms
path: '/api/auth/refresh' // Scope to the refresh endpoint only
});
// Send the short-lived access token in the response body
// Client stores it in memory (JS variable), not localStorage
res.json({ accessToken, user: { id: user.id, email: user.email } });
Defending Against Common Web Attacks
Good auth doesn't help much if someone can inject SQL, run scripts in your users' browsers, or forge requests on their behalf. None of these attacks need a valid password.
SQL Injection Prevention
// VULNERABLE: String interpolation lets attackers inject arbitrary SQL
// Attacker sends email: ' OR '1'='1
const query = `SELECT * FROM users WHERE email = '${req.body.email}'`;
// Resulting SQL: SELECT * FROM users WHERE email = '' OR '1'='1' -- returns ALL users!
// SECURE: Parameterized queries with mysql2
const [rows] = await db.execute(
'SELECT * FROM users WHERE email = ? AND active = ?',
[req.body.email, true]
);
// SECURE: With pg (PostgreSQL), using $1, $2 placeholders
const { rows } = await pool.query(
'SELECT id, email, role FROM users WHERE email = $1',
[req.body.email]
);
// SECURE: Knex.js auto-parameterizes all values
const user = await knex('users')
.where({ email: req.body.email, active: true })
.first();
NoSQL Injection Prevention
Using MongoDB doesn't get you off the hook. If user input goes straight into a Mongoose query, an attacker can send a JSON object full of query operators where you expected a string:
// VULNERABLE: If attacker sends { "username": { "$gt": "" } }
// MongoDB operator "$gt" means greater-than, so this matches ALL users
User.findOne({ username: req.body.username });
// SECURE: Use express-mongo-sanitize to strip $ and . operators globally
const mongoSanitize = require('express-mongo-sanitize');
app.use(mongoSanitize({ replaceWith: '_' }));
// SECURE: Explicit type enforcement before any DB query
function sanitizeString(input, maxLen = 100) {
if (typeof input !== 'string') throw new Error('Invalid input type');
return input.trim().slice(0, maxLen);
}
const username = sanitizeString(req.body.username, 50);
const user = await User.findOne({ username });
Each request passes through the layers in order, and bad traffic gets rejected at the first one that catches it
CSRF Protection
CSRF works by getting a logged-in user's browser to send a request from some other site, with their cookies attached. If you use cookie-based sessions, you need CSRF tokens or SameSite cookies. If your API takes a JWT in the Authorization header instead, CSRF mostly isn't a concern, because another origin can't set that header on the user's behalf.
The csurf package below was deprecated by the Express team in 2022 and isn't maintained anymore. It still works for existing apps, but for new projects look at a maintained alternative such as csrf-csrf (double-submit cookie pattern), or rely on sameSite cookies plus an Origin header check.
// Option 1: csurf middleware for form-based apps
const csrf = require('csurf');
const csrfProtection = csrf({ cookie: { httpOnly: true, secure: true } });
app.get('/checkout', csrfProtection, (req, res) => {
res.render('checkout', { csrfToken: req.csrfToken() });
});
app.post('/checkout', csrfProtection, (req, res) => {
// Token is verified automatically, so it's safe to proceed
processOrder(req.body);
});
// Option 2: SameSite cookies (modern browsers, no extra library)
app.use(session({
secret: process.env.SESSION_SECRET,
cookie: {
httpOnly: true,
secure: true,
sameSite: 'strict' // Browser refuses to send cookie on cross-origin requests
}
}));
HTTP Security Headers with Helmet.js
If you only do one thing from this post today, add Helmet. It's one line, and it sets a bunch of response headers (CSP, HSTS, frame options and more) that block or limit well-known browser attacks. For the effort involved, nothing else here comes close.
Install Helmet
Add it with npm install helmet. It has zero runtime dependencies and adds negligible overhead.
Apply Before Any Routes
Call app.use(helmet()) as the first middleware. Security headers must be set before the response is written.
Configure Content Security Policy
The default CSP is strict. Add your CDN domains and any inline styles you really need, or your own scripts and fonts will stop loading. The browser console will tell you exactly which directive blocked what.
const helmet = require('helmet');
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'strict-dynamic'"],
styleSrc: ["'self'", 'https:', "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'"],
fontSrc: ["'self'", 'https:', 'data:'],
objectSrc: ["'none'"], // Block Flash, Java applets
frameAncestors: ["'none'"], // Prevent clickjacking via iframes
upgradeInsecureRequests: [], // Force HTTPS for all subresources
},
},
hsts: {
maxAge: 31536000, // 1 year in seconds
includeSubDomains: true,
preload: true // Submit domain to browser HSTS preload list
},
referrerPolicy: {
policy: 'strict-origin-when-cross-origin'
},
}));
| Header Set by Helmet | Protects Against |
|---|---|
Content-Security-Policy |
XSS, code injection via unauthorized scripts/styles |
Strict-Transport-Security |
Man-in-the-middle attacks, HTTP downgrade attacks |
X-Frame-Options |
Clickjacking attacks via invisible iframes |
X-Content-Type-Options |
MIME-type sniffing that executes scripts as HTML |
Referrer-Policy |
Sensitive URL leakage in HTTP Referer headers |
Permissions-Policy |
Unauthorized use of camera, microphone, geolocation |
Rate Limiting, Dependencies, and Secrets
You can get everything above right and still get hurt by a credential-stuffing script hammering your login route, a compromised npm package, or a database password sitting in your git history.
Rate Limiting with express-rate-limit
const rateLimit = require('express-rate-limit');
// General API rate limit: 100 requests per 15 minutes per IP
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true, // Return rate info in RateLimit-* headers (RFC 6585)
legacyHeaders: false,
message: { error: 'Too many requests. Please wait before trying again.' },
});
// Strict limit for auth endpoints to prevent brute force
const authLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour window
max: 10,
skipSuccessfulRequests: true, // Only count failed attempts
message: { error: 'Too many login attempts. Try again in an hour.' },
keyGenerator: (req) => req.body.email || req.ip, // Track by email, not just IP
});
// Password reset: very strict (attackers use this for account enumeration)
const resetLimiter = rateLimit({
windowMs: 24 * 60 * 60 * 1000, // 24 hours
max: 3,
message: { error: 'Password reset limit reached. Try again tomorrow.' },
});
// Apply to routes
app.use('/api', apiLimiter);
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/forgot-password', resetLimiter);
module.exports = { apiLimiter, authLimiter, resetLimiter };
If the app runs behind Nginx, a load balancer, or Cloudflare, req.ip is the proxy's address unless you call app.set('trust proxy', 1) (or the number of hops you actually have). Skip it and every visitor shares one rate limit bucket, so a single busy user can lock everyone out. Recent versions of express-rate-limit catch this and log an ERR_ERL_UNEXPECTED_X_FORWARDED_FOR validation error. Don't "fix" it with trust proxy: true on a box that's directly reachable, though, or clients can spoof their IP through the header.
Dependency Security
A normal Express project pulls in hundreds of transitive packages, and any of them can turn malicious. The event-stream compromise (2018) and the ua-parser-js hijacking (2021) both involved popular packages where an attacker got publish access and shipped malware to everyone who updated.
# Audit current dependencies for known CVEs
npm audit
# Auto-fix vulnerabilities where safe to do so
npm audit fix
# Use npm ci in CI/CD: it's deterministic (uses package-lock.json exactly)
npm ci
# Snyk: deeper analysis, monitoring, and pull request alerts
npm install -g snyk
snyk auth
snyk test # One-time scan
snyk monitor # Continuous monitoring of production dependencies
# Lock Node.js version in package.json to prevent runtime surprises
# "engines": { "node": ">=20.0.0 <21.0.0" }
# Use .npmrc to prevent accidental publishing of private packages
# echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc
Secrets Management
Hardcoded credentials cause some of the most avoidable breaches there are. Secret scanners find API keys and database URLs in public repos constantly, and bots scrape GitHub for them within minutes of a push. Keep secrets out of the code, and check at startup that the ones you need are actually set.
// .env file: NEVER commit to version control
// DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
// JWT_ACCESS_SECRET=minimum-32-character-random-secret-here
// JWT_REFRESH_SECRET=another-different-32-char-secret-here
// .gitignore: protect your secrets
// .env
// .env.local
// .env.production
// config/env.js: validate at startup, fail fast if misconfigured
const requiredEnvVars = [
'DATABASE_URL',
'JWT_ACCESS_SECRET',
'JWT_REFRESH_SECRET',
'SESSION_SECRET',
];
function validateEnv() {
const missing = requiredEnvVars.filter(key => !process.env[key]);
if (missing.length > 0) {
console.error('Missing required environment variables:', missing.join(', '));
process.exit(1);
}
// Enforce minimum secret strength
if (process.env.JWT_ACCESS_SECRET.length < 32) {
console.error('JWT_ACCESS_SECRET must be at least 32 characters');
process.exit(1);
}
}
// Call this before app.listen(). Never start with incomplete config
validateEnv();
module.exports = {
db: { url: process.env.DATABASE_URL },
jwt: {
accessSecret: process.env.JWT_ACCESS_SECRET,
refreshSecret: process.env.JWT_REFRESH_SECRET,
},
};
If a secret leaks (an accidental commit, a log file, an error message), assume every token signed with it is compromised. Rotating the JWT secret invalidates all existing access and refresh tokens, so every user has to log in again. That's annoying, but it's the right call. Deleting the commit isn't enough either, since the secret is still in git history and in any clone or fork. To catch the next one before it lands, add git-secrets or GitHub's push protection to your workflow.
Production Hardening and Error Handling
In production you want the app to tell attackers as little as possible about itself, while logging enough that you can work out what happened afterwards.
- Disable X-Powered-By:
app.disable('x-powered-by')stops advertising your stack. Helmet already removes it, so you only need this if you're not using Helmet. - Run as non-root: Your Node.js process should never run as root. Use a dedicated service account with only the filesystem permissions it needs.
- Set memory limits: Use
--max-old-space-sizeand OS resource limits so a memory exhaustion attack can't drag down other services on the same host. - Enable structured logging: Log auth events, rate limit hits and validation failures with
pinoorwinston. JSON logs are far easier to search and correlate in a SIEM than free text. - Use a process manager: PM2 or systemd will restart a crashed process, so one crash doesn't turn into a long outage.
// 4-parameter signature tells Express this is an error handler
// Register AFTER all routes
app.use((err, req, res, next) => {
// Log the full error internally so you never lose diagnostic context
logger.error({
message: err.message,
stack: err.stack,
url: req.url,
method: req.method,
ip: req.ip,
userId: req.user?.id,
});
// Known operational errors get specific, safe messages
if (err.name === 'ValidationError') {
return res.status(400).json({ error: err.message });
}
if (err.name === 'UnauthorizedError') {
return res.status(401).json({ error: 'Authentication required' });
}
// Unknown errors: generic response only. No stack traces, no internals
res.status(err.statusCode || 500).json({
error: process.env.NODE_ENV === 'production'
? 'An internal error occurred'
: err.message // Detailed only in development
});
});
One more thing the handler above doesn't cover: errors thrown outside a request. Add process.on('unhandledRejection') and process.on('uncaughtException') handlers that log the error and then exit, and let PM2 or systemd restart the process. Trying to keep running after an uncaught exception leaves the app in an unknown state, which is worse than a quick restart.
What I Check Before Going to Production
Pre-Deploy Checks
- Input Validation: Every external input goes through Joi or express-validator before business logic
- Parameterized Queries: No string-concatenated SQL or NoSQL queries anywhere in the codebase
- Password Hashing: bcrypt with cost factor ≥12 (never MD5, SHA-1, or plaintext)
- JWT Security: 15-minute access tokens, algorithm whitelist, httpOnly refresh cookies
- Security Headers: Helmet.js with a strict Content-Security-Policy
- Rate Limiting: On all routes, much stricter on auth endpoints, with
trust proxyset correctly - Dependency Audits:
npm auditin CI, Snyk monitoring in production - Secrets Management: Environment variables only, validated at startup, with a written rotation plan
- HTTPS Everywhere: HSTS header, HTTP → HTTPS redirect at the ingress
- Error Handling: Stack traces and internal details never reach production clients
Not all of this is worth doing on day one, though. Some trade-offs I'd think about:
- Don't add CSRF tokens to a pure header-token API. If the browser never sends auth automatically (no cookies), CSRF middleware just adds complexity. The refresh-token cookie is the exception, and
sameSite: 'strict'plus a narrowpathusually covers it. - Be careful with HSTS
preload. Once your domain is on the preload list, every subdomain has to serve HTTPS, and getting removed takes months. Leavepreloadoff until you're sure. - Don't run
npm audit fix --forceblindly. It will happily install breaking major versions. Plenty of audit findings are in dev-only tooling that never runs in production, so triage them rather than treating each one as an outage. - Per-instance rate limits break when you scale out. The default memory store keeps counts per process, so three instances behind a load balancer means three times the limit. Use a Redis store once you run more than one instance.
- Don't push bcrypt's cost too high. Every login burns CPU on the hash. Push the cost factor high enough and your own login endpoint turns into an easy DoS target. Measure on your actual hardware and pick a cost that keeps a hash around a few hundred milliseconds.
None of this is a one-off job. Subscribe to the Node.js security releases, keep dependencies moving with something like Dependabot or Renovate, and put a recurring review on the calendar so security doesn't only get looked at the week before launch.