Skip to content

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

next -> sec_02_crypto.md