OWASP Top 10 for Node.js Backends: A Practical Security Guide for 2026
Introduction
The OWASP Top 10 is the industry benchmark for web application security — but its generic descriptions leave Node.js teams guessing how each category maps to their stack. Does "Broken Access Control" mean the same thing in Express as it does in a Java servlet container? What does "Insecure Design" look like in a JavaScript microservice?
This guide translates the 2021 OWASP Top 10 into concrete, actionable patterns for Node.js backends. For each category you'll see the actual vulnerable code, how an attacker would exploit it, and the production-grade fix. Use this as your team's quick-reference during security audits and code reviews.
A01:2021 — Broken Access Control
Access control is the most prevalent vulnerability in the OWASP Top 10 — and the most commonly misconfigured in Node.js applications. The classic mistake: relying on client-side role information.
Vulnerable Pattern
// [X] DANGEROUS: Trusts client-provided role
app.get('/api/admin/users', (req, res) => {
// The role comes from a decoded JWT that wasn't validated
// or worse — from a query parameter
if (req.query.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
const users = await db.user.findMany();
res.json(users);
});
An attacker can simply change the role parameter and escalate privileges.
The Fix
// [V] SECURE: Server-enforced access control
const jwt = require('jsonwebtoken');
function requireRole(...allowedRoles) {
return (req, res, next) => {
// Extract role from the server-validated JWT payload
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Unauthorized' });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (!allowedRoles.includes(decoded.role)) {
return res.status(403).json({ error: 'Forbidden' });
}
req.user = decoded;
next();
} catch {
return res.status(401).json({ error: 'Invalid token' });
}
};
}
app.get('/api/admin/users', requireRole('admin'), async (req, res) => {
const users = await db.user.findMany();
res.json(users);
});
Key rules:
- Never trust
req.query,req.body, orreq.paramsfor authorization decisions - Validate roles from server-signed tokens or session data only
- Enforce access control at every endpoint, not just in the frontend
- Test negative cases: unauthenticated, wrong role, expired token
A02:2021 — Cryptographic Failures
Node.js makes cryptography surprisingly easy to get wrong. The most common failures are weak password hashing, using crypto.createHash instead of a proper KDF, and hardcoding secrets.
Vulnerable Pattern
// [X] DANGEROUS: Weak password hashing
const crypto = require('crypto');
function hashPassword(password) {
return crypto.createHash('sha256').update(password).digest('hex');
// SHA-256 is designed for speed — an attacker can try billions of guesses per second
}
The Fix
// [V] SECURE: Proper password hashing with bcrypt
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12; // Higher = slower. 12 is the current sweet spot.
async function hashPassword(password) {
const salt = await bcrypt.genSalt(SALT_ROUNDS);
return bcrypt.hash(password, salt);
}
async function verifyPassword(password, hash) {
return bcrypt.compare(password, hash);
}
Additional crypto rules:
- Use
crypto.timingSafeEqual()for comparing sensitive values (prevents timing attacks) - Never hardcode secrets — use environment variables or a secrets manager like Vault
- Encrypt data at rest with AES-256-GCM (authenticated encryption)
- Use TLS 1.3 exclusively; disable TLS 1.0/1.1 on your load balancer
// Timing-safe comparison example
function safeCompare(userInput, actualSecret) {
if (userInput.length !== actualSecret.length) {
// Don't short-circuit on length — still compare to prevent timing oracle
return crypto.timingSafeEqual(
Buffer.from(userInput),
Buffer.from(actualSecret)
);
}
return crypto.timingSafeEqual(
Buffer.from(userInput),
Buffer.from(actualSecret)
);
}
A03:2021 — Injection
SQL/NoSQL injection remains devastating in Node.js apps, particularly with raw MongoDB queries and string-concatenated SQL.
Vulnerable Pattern
// [X] DANGEROUS: String interpolation in MongoDB
app.get('/api/user', async (req, res) => {
const { username } = req.query;
// Direct string interpolation — attacker can inject $ne, $gt, $regex
const user = await db.collection('users').findOne({
username: { $eq: username }
});
// If username = "admin", this works fine.
// If username = { "$ne": "" }, it logs in as ANY user!
});
The Fix
// [V] SECURE: Validate and sanitize all inputs
const { ObjectId } = require('mongodb');
app.get('/api/user', async (req, res) => {
const { username } = req.query;
// 1. Validate type — reject objects and arrays
if (typeof username !== 'string' || username.length > 64) {
return res.status(400).json({ error: 'Invalid username' });
}
// 2. Use parameterized queries (Prisma / Mongoose handle this)
const user = await db.collection('users').findOne({
username: { $eq: username } // Safe: $eq prevents operator injection
});
// 3. Don't include sensitive fields in responses
if (user) {
const { passwordHash, tokenVersion, ...safeUser } = user;
return res.json(safeUser);
}
res.status(404).json({ error: 'User not found' });
});
For SQL databases, always use parameterized queries:
// [V] SECURE: Parameterized SQL with node-postgres
app.get('/api/user', async (req, res) => {
const { id } = req.params;
// Validate that id is a number
if (!/^\d+$/.test(id)) {
return res.status(400).json({ error: 'Invalid ID' });
}
const result = await pool.query(
'SELECT id, name, email FROM users WHERE id = $1',
[id] // Parameterized — never interpolated
);
res.json(result.rows[0] || null);
});
A04:2021 — Insecure Design
Insecure design is about architectural flaws rather than implementation bugs. In Node.js, this most often manifests as missing rate limits on critical endpoints, unbounded resource consumption, and trusting client-generated IDs.
Vulnerable Pattern
// [X] DANGEROUS: Missing rate limit on password reset
app.post('/api/auth/forgot-password', async (req, res) => {
const { email } = req.body;
const user = await db.user.findUnique({ where: { email } });
if (user) {
await sendPasswordResetEmail(email);
}
// Even though we don't reveal if the user exists,
// an attacker can flood the email service and exhaust quota
return res.json({ message: 'If the account exists, a reset email was sent.' });
});
The Fix
// [V] SECURE: Rate-limit critical endpoints and enforce throttling
const rateLimit = require('express-rate-limit');
const passwordResetLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 3, // 3 attempts per window per IP
message: { error: 'Too many requests. Try again later.' },
standardHeaders: true,
legacyHeaders: false,
});
app.post(
'/api/auth/forgot-password',
passwordResetLimiter,
async (req, res) => {
const { email } = req.body;
const user = await db.user.findUnique({ where: { email } });
if (user) {
// Store reset token with expiration
const token = crypto.randomBytes(32).toString('hex');
await db.passwordReset.create({
data: {
email,
token,
expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour
},
});
// Queue email send asynchronously
emailQueue.add({ to: email, token });
}
return res.json({
message: 'If the account exists, a reset email was sent.',
});
}
);
Design-time checklist:
- Every mutation endpoint needs rate limiting
- File upload endpoints need size limits and type validation
- Pagination endpoints need max page size enforcement
- WebSocket connections need connection limits per client
A05:2021 — Security Misconfiguration
Node.js applications ship with a surprising number of default settings that weaken security. The big three: default CORS policies, verbose error messages, and exposed dependency metadata.
Vulnerable Patterns
// [X] DANGEROUS: Overly permissive CORS during development, promoted to production
app.use(cors()); // Accepts any origin by default!
// [X] DANGEROUS: Verbose error responses leak stack traces
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message, stack: err.stack });
});
The Fix
// [V] SECURE: Strict CORS with explicit origin allowlist
const cors = require('cors');
const ALLOWED_ORIGINS = [
'https://app.yourstartup.com',
'https://admin.yourstartup.com',
];
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (server-to-server, mobile apps)
if (!origin) return callback(null, true);
if (ALLOWED_ORIGINS.includes(origin)) {
return callback(null, true);
}
return callback(new Error('Not allowed by CORS'));
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
// [V] SECURE: Sanitize error responses
app.use((err, req, res, next) => {
console.error('[ERROR]', err); // Log the full error internally
res.status(err.status || 500).json({
error: process.env.NODE_ENV === 'production'
? 'Internal server error'
: err.message,
});
});
Additional hardening:
- Remove the
X-Powered-By: Expressheader - Disable directory listing on Express static file serving
- Set
Helmetmiddleware for secure HTTP headers - Never run
npm install --productionin the same step as development installs in CI
// Hardening middleware with Helmet
const helmet = require('helmet');
app.use(helmet());
app.disable('x-powered-by');
A06:2021 — Vulnerable and Outdated Components
This is the widest attack surface in the Node.js ecosystem. A typical startup app has 500-1500 direct and transitive dependencies. One forgotten package with a known CVE can compromise your entire application.
The Approach
# Scan for known vulnerabilities
npm audit # Basic scan (covers direct deps)
npm audit --audit-level=high # Fail CI on high-severity issues
# Or use Snyk for deeper analysis
npx snyk test --all-projects # Scopes transitive deps too
npx snyk monitor # Continuous monitoring via Snyk dashboard
Production dependency hygiene:
- Pin exact versions in
package.json(remove^and~for critical deps) - Use
npm ls --depth=0weekly to audit your direct dependency tree - Set up Dependabot or Renovate to auto-create PRs for vulnerable deps
- Remove unused dependencies with
depcheckornpm prune - For packages with native bindings (node-gyp), pin major versions explicitly
{
"dependencies": {
"express": "4.20.0",
"jsonwebtoken": "9.0.2",
"mongoose": "8.5.0"
}
}
A07:2021 — Identification and Authentication Failures
We covered auth in depth in the Auth & Session Management guide, but the Node.js-specific pitfalls deserve their own mention.
Vulnerable Pattern
// [X] DANGEROUS: Session fixation — accepting pre-set session IDs
app.post('/api/auth/login', async (req, res) => {
const { email, password, sessionId } = req.body;
const user = await authenticateUser(email, password);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
// Reuses the client-provided session ID — attacker can set this before login!
await db.session.create({
data: { id: sessionId || crypto.randomUUID(), userId: user.id },
});
res.cookie('session_id', sessionId, { httpOnly: true });
});
The Fix
// [V] SECURE: Always generate fresh session IDs server-side
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await authenticateUser(email, password);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
// Always generate a NEW session ID on login
const session = await db.session.create({
data: {
id: crypto.randomUUID(),
userId: user.id,
createdAt: new Date(),
lastActivity: new Date(),
},
});
res.cookie('session_id', session.id, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000,
});
res.json({ user: { id: user.id, name: user.name, email: user.email } });
});
A08:2021 — Software and Data Integrity Failures
In Node.js, this primarily means supply chain attacks — compromised npm packages that execute malicious code during install or runtime.
Defensive Measures
# 1. Lock dependencies with package-lock.json (committed to git!)
npm install
# 2. Enable npm integrity verification
npm config set audit true
npm config set fund false
# 3. Use exact versions for production dependencies
npm config set save-exact true
// [V] SECURE: Validate webhook payloads with a secret
const crypto = require('crypto');
app.post('/api/webhooks/github', (req, res) => {
const signature = req.headers['x-hub-signature-256'];
const payload = JSON.stringify(req.body);
const expected = `sha256=${
crypto.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(payload)
.digest('hex')
}`;
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Process webhook safely
res.status(200).end();
});
A09:2021 — Logging and Monitoring Failures
You can't respond to a breach you don't detect. Most Node.js startups log aggressively in development and log nothing in production — or worse, they log sensitive data.
Vulnerable Pattern
// [X] DANGEROUS: Logging sensitive data
app.use((req, res, next) => {
console.log(`${req.method} ${req.path}`, req.body);
// req.body may contain passwords, tokens, credit cards...
next();
});
The Fix
// [V] SECURE: Structured logging with sensitive data redaction
const pino = require('pino');
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
redact: {
paths: [
'req.headers.authorization',
'req.body.password',
'req.body.token',
'req.body.secret',
'req.body.creditCard',
],
censor: '[REDACTED]',
},
transport: {
target: 'pino-pretty',
options: { colorize: process.env.NODE_ENV !== 'production' },
},
});
app.use((req, res, next) => {
logger.info({ req, method: req.method, path: req.path }, 'Incoming request');
next();
});
// Monitor security events specifically
function auditLog(event, metadata) {
logger.warn({ event, metadata, timestamp: new Date().toISOString() },
'Security event');
}
// Usage
auditLog('LOGIN_FAILURE', { email: req.body.email, ip: req.ip });
auditLog('ACCESS_DENIED', { userId: req.user?.id, resource: req.path });
Monitoring essentials:
- Centralized log aggregation (Sentry, DataDog, or ELK stack)
- Real-time alerts for: repeated 401/403 responses, unusual IP patterns, elevated error rates
- Session replay or audit trail for sensitive operations (transfers, account changes)
- Log retention policy aligned with your compliance requirements (SOC 2, GDPR)
A10:2021 — Server-Side Request Forgery (SSRF)
SSRF is increasingly dangerous in cloud-native Node.js apps that fetch user-provided URLs, especially when deployed on AWS/GCP where metadata endpoints are accessible from within the VPC.
Vulnerable Pattern
// [X] DANGEROUS: Fetching user-supplied URLs
app.post('/api/fetch-meta', async (req, res) => {
const { url } = req.body;
// An attacker can submit:
// http://169.254.169.254/latest/meta-data/iam/security-credentials/
// ...and steal your cloud provider's IAM credentials
const response = await fetch(url);
const data = await response.text();
res.json({ data });
});
The Fix
// [V] SECURE: Validate and restrict outbound URLs
const { URL } = require('url');
const ALLOWED_HOSTS = [
'api.github.com',
'api.stripe.com',
'api.sendgrid.com',
];
const BLOCKED_IPS = ['169.254.169.254', '127.0.0.1', '0.0.0.0', '::1'];
const PRIVATE_RANGES = [/^10\./, /^172\.(1[6-9]|2\d|3[01])\./, /^192\.168\./];
const dns = require('dns').promises;
async function validateExternalUrl(urlString) {
let url;
try {
url = new URL(urlString);
} catch {
throw new Error('Invalid URL');
}
// Only allow HTTPS
if (url.protocol !== 'https:') {
throw new Error('Only HTTPS URLs are allowed');
}
// Only allow specific hosts
if (!ALLOWED_HOSTS.includes(url.hostname)) {
throw new Error('Host not in allowlist');
}
// Resolve DNS and block private IPs
const addresses = await dns.resolve4(url.hostname);
for (const ip of addresses) {
if (BLOCKED_IPS.includes(ip)) {
throw new Error('Blocked IP address');
}
if (PRIVATE_RANGES.some(range => range.test(ip))) {
throw new Error('Private IP ranges are blocked');
}
}
return url;
}
app.post('/api/fetch-meta', async (req, res) => {
try {
const validatedUrl = await validateExternalUrl(req.body.url);
const response = await fetch(validatedUrl, {
signal: AbortSignal.timeout(5000),
});
const data = await response.text();
res.json({ data });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
OWASP Top 10 Node.js Audit Checklist
Use this checklist during your next Node.js security review:
- [ ] A01 — Broken Access Control: Every endpoint enforces authorization server-side. No client-provided role/sub claim is trusted.
- [ ] A02 — Cryptographic Failures: Passwords hashed with bcrypt (≥12 rounds) or Argon2. All secrets in env vars or vault. AES-256-GCM for data at rest.
- [ ] A03 — Injection: No raw SQL/Mongo operator injection. All queries use parameterized inputs or ORM abstractions.
- [ ] A04 — Insecure Design: Rate limiting on all mutation endpoints. Max pagination size enforced. File uploads validated by type and size.
- [ ] A05 — Security Misconfiguration: CORS is origin-allowlisted. Error responses redact internals in production. Helmet middleware active. X-Powered-By removed.
- [ ] A06 — Vulnerable Components:
npm auditpasses at high/severity level. Dependencies pinned to exact versions. No unused packages. Dependabot active. - [ ] A07 — Auth Failures: Session IDs are server-generated, rotated on login/logout. Idle (30min) and absolute (24h) timeouts configured.
- [ ] A08 — Integrity Failures:
package-lock.jsoncommitted. Webhook signatures verified. npm integrity checks enabled. - [ ] A09 — Logging & Monitoring: Structured logging with redaction. Security events (failed logins, access denials) audited. Alerting configured.
- [ ] A10 — SSRF: Outbound HTTP requests to allowlisted hosts only. Private IP ranges blocked. HTTPS enforced. Timeouts configured.
Conclusion
The OWASP Top 10 is not a theoretical framework — it's a map of the vulnerabilities that actually get exploited in production. For Node.js teams, each category maps to concrete code patterns you can audit, fix, and verify in CI.
Start with the checklist above in your next sprint. Even fixing the top three categories (Broken Access Control, Cryptographic Failures, and Injection) eliminates the majority of critical-severity vulnerabilities found in production audits.
The cost of fixing these early is measured in hours of refactoring. The cost of finding them in a penetration test — or worse, in a breach notification — is measured in reputational damage, customer churn, and legal liability. Pick one category this week, audit your codebase, and ship the fix. Your future self (and your users) will thank you.
JS Security Audit
Senior JavaScript security consultants with 10+ years of experience.