← Back to Blog
Node.jsOWASPExpressBackend SecuritySQL InjectionSecurity ChecklistAPI Security

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

js
// [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

js
// [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, or req.params for 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

js
// [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

js
// [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
js
// 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

js
// [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

js
// [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:

js
// [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

js
// [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

js
// [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

js
// [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

js
// [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: Express header
  • Disable directory listing on Express static file serving
  • Set Helmet middleware for secure HTTP headers
  • Never run npm install --production in the same step as development installs in CI
js
// 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

bash
# 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=0 weekly to audit your direct dependency tree
  • Set up Dependabot or Renovate to auto-create PRs for vulnerable deps
  • Remove unused dependencies with depcheck or npm prune
  • For packages with native bindings (node-gyp), pin major versions explicitly
json
{
  "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

js
// [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

js
// [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

bash
# 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
js
// [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

js
// [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

js
// [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

js
// [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

js
// [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 audit passes 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.json committed. 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.

Share this article:TwitterLinkedIn
JS

JS Security Audit

Senior JavaScript security consultants with 10+ years of experience.