OWASP Top 10 in Node.js¶
Every damn app ships with the same mistakes You'd think after 20 years of OWASP we'd stop copy-pasting vulnerabilities into production but here we are still finding eval() in Express routes and JWT secrets set to "secret" in 2026 Let's map the Top 10 to Node.js because knowing them isn't enough - you need to see how they manifest in your actual code
A01: Broken Access Control¶
Missing auth checks in routes is the most common fuckup in Node apps
// DON'T: no auth check
app.get('/api/admin/users', async (req, res) => {
const users = await User.find()
res.json(users)
})
// DO: middleware guard
function requireAdmin(req, res, next) {
if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({ error: 'gtfo' })
}
next()
}
app.get('/api/admin/users', requireAdmin, async (req, res) => {
const users = await User.find()
res.json(users)
})
Object-level access control fails too - users accessing another user's data by changing an ID parameter Always check ownership : never trust the client
A02: Cryptographic Failures¶
Weak hashing , bad JWT secrets , using MD5 for passwords in 2026
// DON'T: this is criminal
const hash = crypto.createHash('md5').update(password).digest('hex')
// DON'T: weak JWT secret
const token = jwt.sign({ userId: 1 }, 'secret123')
// DO: proper hashing with bcrypt
const bcrypt = require('bcrypt')
const hash = await bcrypt.hash(password, 12)
// DO: strong JWT secret from env
const token = jwt.sign({ userId: 1 }, process.env.JWT_SECRET, { expiresIn: '15m' })
TLS is non-negotiable in 2026 : if you're not serving HTTPS you're asking for credential theft on every request
A03: Injection¶
SQL injection is alive and well in Node.js because devs love string concatenation
// DON'T: string interpolation in SQL
const query = `SELECT * FROM users WHERE email = '${email}'`
// DO: parameterized queries with pg
const { Pool } = require('pg')
const pool = new Pool()
const { rows } = await pool.query(
'SELECT * FROM users WHERE email = $1',
[email]
)
NoSQL injection hits MongoDB apps hard - attackers use $gt , $ne , and $where to bypass auth Sanitize the hell out of any object passed to find() or findOne()
A04: Insecure Design¶
Rate limiting absence is the classic - no lockout on login endpoints means brute force is inevitable
// DON'T: unlimited login attempts
app.post('/api/login', async (req, res) => {
const user = await User.findOne({ email: req.body.email })
// infinite tries...
})
// DO: rate limit with express-rate-limit
const rateLimit = require('express-rate-limit')
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
message: 'Too many attempts , try again later'
})
app.post('/api/login', loginLimiter, async (req, res) => {
// auth logic here
})
Trust assumptions kill : don't assume internal requests are safe , don't assume microservices validate
A05: Security Misconfiguration¶
Debug enabled in prod is a showstopper - attackers get full stack traces with file paths
// DON'T: debug mode in production
app.set('env', 'development') // stack traces leak everywhere
// DON'T: default creds
const admin = new User({ username: 'admin', password: 'admin' })
// DO: enforce env-based config
if (app.get('env') === 'production') {
app.set('env', 'production')
app.disable('x-powered-by')
}
Default Express settings expose x-powered-by: Express - gives attackers intel on your stack Disable it. Always.
A06: Vulnerable Components¶
Outdated packages are the easiest path to RCE - just ask the event-stream incident
# Check what you're shipping
npm audit
# With severity filter
npm audit --audit-level=high
# In CI - fail on vulnerabilities
npm audit --audit-level=critical || exit 1
Run npm audit in CI pipeline : don't let developers merge packages with known RCEs into production
A07: Authentication Failures¶
JWT alg=none bypass is the dumbest vulnerability that still works everywhere
// DON'T: vulnerable to alg=none attack
const decoded = jwt.verify(token, secret) // no algorithm check
// DO: enforce algorithm
const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] })
// DON'T: no expiry
const token = jwt.sign({ userId: 1 }, secret)
// DO: short-lived tokens
const token = jwt.sign({ userId: 1 }, secret, { expiresIn: '15m' })
Session fixation : generate new session IDs on login , never accept session IDs from URL params
A08: Data Integrity Failures¶
Deserialization attacks in Node.js - eval() , vm.run() , and unsafe JSON parsing
// DON'T: unsafe deserialization
const data = eval(`(${userInput})`) // RCE playground
// DON'T: node-serialize
const deserialized = require('node-serialize').unserialize(userInput)
// DO: JSON.parse with validation
const schema = require('./schemas/userSchema')
let parsed
try {
parsed = JSON.parse(userInput)
parsed = schema.parse(parsed) // Zod schema validation
} catch (err) {
res.status(400).json({ error: 'invalid input' })
}
Never use eval() , new Function() , or vm.run() with user input - that's shell territory
A09: Logging Failures¶
Not logging security events means you're blind during an incident
// DON'T: no logging
app.post('/api/login', async (req, res) => {
// no record of failed attempts
})
// DO: structured security logging with pino
const pino = require('pino')
const logger = pino({ level: 'info' })
app.post('/api/login', async (req, res) => {
const user = await User.findOne({ email: req.body.email })
if (!user || !bcrypt.compareSync(req.body.password, user.password)) {
logger.warn({ email: req.body.email }, 'failed login attempt')
return res.status(401).json({ error: 'invalid credentials' })
}
logger.info({ userId: user.id }, 'successful login')
// issue token
})
Never log passwords , tokens , or PII - log events not secrets
A10: SSRF¶
Server-Side Request Forgery - fetching user-provided URLs without validation
// DON'T: fetch user-provided URL directly
app.post('/api/fetch', async (req, res) => {
const response = await fetch(req.body.url)
const data = await response.text()
res.send(data)
})
// DO: validate and allowlist
const { URL } = require('url')
const ALLOWED_DOMAINS = ['api.trusted.com', 'data.internal.com']
app.post('/api/fetch', async (req, res) => {
try {
const parsed = new URL(req.body.url)
if (!ALLOWED_DOMAINS.includes(parsed.hostname)) {
return res.status(400).json({ error: 'domain not allowed' })
}
// prevent internal network access
const ip = await dnsLookup(parsed.hostname)
if (isPrivateIP(ip)) {
return res.status(400).json({ error: 'internal IPs blocked' })
}
const response = await fetch(req.body.url)
const data = await response.text()
res.send(data)
} catch (err) {
res.status(400).json({ error: 'invalid URL' })
}
})
Block private IP ranges , loopback addresses , and metadata endpoints (169.254.169.254)
Prerequisites¶
- perf_03_load_testing.md - understand app performance before securing it
next -> sec_02_crypto.md