Security

Securing Node.js Applications

Mayur Dabhi
Mayur Dabhi
April 29, 2026
15 min read

Node.js is used by Netflix, LinkedIn, and PayPal to handle millions of concurrent requests daily. Its non-blocking, event-driven architecture makes it exceptionally fast—but security must be deliberately engineered in. Unlike monolithic frameworks that bundle security defaults, Node.js gives developers raw power with minimal guardrails. That freedom means you own every security decision. This guide covers the essential security measures every production Node.js application needs, with practical code you can apply immediately.

The Stakes Are High

According to the 2023 Verizon Data Breach Investigations Report, web application attacks account for over 26% of all breaches. Node.js applications—often serving as API backends handling authentication tokens, personal data, and payment information—are prime targets. Security is not optional; it's part of the feature set.

Understanding the Node.js Threat Landscape

Before hardening your application, you need to understand what you're protecting against. Node.js applications face both the standard OWASP Top 10 vulnerabilities and Node-specific risks that stem from JavaScript's dynamic nature and the npm ecosystem.

The Most Dangerous Attack Vectors

Attack Type Risk Level Primary Defense
SQL / NoSQL Injection Critical Parameterized queries, input validation
Cross-Site Scripting (XSS) High Output encoding, Content Security Policy
Broken Authentication Critical bcrypt, JWT best practices, MFA
CSRF Attacks High CSRF tokens, SameSite cookies
Prototype Pollution High Input sanitization, Object.create(null)
Dependency Vulnerabilities Medium–Critical npm audit, Snyk, automated updates
Secrets Exposure Critical Environment variables, secrets management
Rate Limiting Bypass Medium express-rate-limit, IP-based throttling

Node.js also faces a unique challenge: its single-threaded event loop means a single poorly handled asynchronous error or a ReDoS attack (regular expression denial of service) can bring down the entire application. Addressing these threats systematically is what separates a vulnerable prototype from a production-grade service.

Input Validation and Sanitization

The first and most important rule of security: never trust user input. Every piece of data entering your application—request bodies, query parameters, headers, cookies—must be validated against a strict schema before it touches your business logic or database.

Validating with express-validator

Express-validator is a set of middlewares that wraps the validator.js library, providing chainable validation rules that integrate cleanly with Express routes.

middleware/validate.js
const { body, validationResult } = require('express-validator');

const userValidation = {
  register: [
    body('email')
      .isEmail().withMessage('Invalid email format')
      .normalizeEmail()
      .isLength({ max: 255 }),
    body('password')
      .isLength({ min: 8 }).withMessage('Password must be at least 8 characters')
      .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/)
      .withMessage('Password must contain uppercase, lowercase, number, and special char'),
    body('username')
      .trim()
      .isAlphanumeric().withMessage('Username can only contain letters and numbers')
      .isLength({ min: 3, max: 30 }),
  ],
};

// Middleware to check results and respond with errors
function validate(req, res, next) {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(400).json({
      errors: errors.array().map(e => ({ field: e.path, message: e.msg }))
    });
  }
  next();
}

module.exports = { userValidation, validate };
routes/auth.js
const { userValidation, validate } = require('../middleware/validate');

router.post('/register',
  userValidation.register,
  validate,
  authController.register  // Invalid data never reaches the controller
);

Schema Validation with Joi

Joi excels at validating complex nested objects, particularly API request bodies. Its expressive schema language makes it easy to enforce both shape and business rules:

validation/schemas.js
const Joi = require('joi');

const createOrderSchema = Joi.object({
  items: Joi.array().items(
    Joi.object({
      productId: Joi.string().uuid().required(),
      quantity: Joi.number().integer().min(1).max(100).required(),
    })
  ).min(1).max(50).required(),

  shippingAddress: Joi.object({
    street: Joi.string().trim().max(200).required(),
    city: Joi.string().trim().max(100).required(),
    postalCode: Joi.string().pattern(/^[A-Z0-9]{3,10}$/i).required(),
    country: Joi.string().length(2).uppercase().required(),
  }).required(),

  couponCode: Joi.string().alphanum().max(20).optional(),
});

// Middleware factory
function joiValidate(schema) {
  return (req, res, next) => {
    const { error, value } = schema.validate(req.body, {
      abortEarly: false,   // Return all errors, not just first
      stripUnknown: true,  // Remove fields not in schema (prevents mass assignment)
      convert: true,       // Type coercion (string to number where declared)
    });

    if (error) {
      return res.status(400).json({
        errors: error.details.map(d => ({ field: d.path.join('.'), message: d.message }))
      });
    }

    req.body = value; // Use the cleaned, validated value
    next();
  };
}
Always stripUnknown

Configure Joi with stripUnknown: true. This removes any fields the client sends that aren't in your schema—preventing mass assignment vulnerabilities where attackers inject fields like isAdmin: true into request bodies that then get passed directly to your ORM's create/update methods.

Securing Authentication and Sessions

Authentication flaws are responsible for some of the most catastrophic breaches. Weak password storage, insecure token handling, and missing brute-force protections are all too common in Node.js applications built without a security-first mindset.

Password Hashing with bcrypt

Never store passwords in plaintext or with fast hashing algorithms like MD5 or SHA-1. These are designed for speed—which makes them ideal for attackers with GPU-accelerated cracking rigs. Use bcrypt, which is intentionally slow and includes per-password salting by design.

services/auth.service.js
const bcrypt = require('bcrypt');

// Cost factor of 12 = ~300ms per hash on modern hardware
// Increase over time as hardware improves
const SALT_ROUNDS = 12;

async function hashPassword(plaintext) {
  return bcrypt.hash(plaintext, SALT_ROUNDS);
}

async function verifyPassword(plaintext, hash) {
  // bcrypt.compare is constant-time — safe against timing attacks
  return bcrypt.compare(plaintext, hash);
}

// During registration
async function registerUser(email, password) {
  const existing = await User.findOne({ email });
  if (existing) {
    // Always hash even if user exists — prevents timing-based email enumeration
    await bcrypt.hash(password, SALT_ROUNDS);
    throw new Error('Registration failed');
  }
  const hashedPassword = await hashPassword(password);
  return User.create({ email, password: hashedPassword });
}

// During login — always return the same generic error
async function loginUser(email, password) {
  const user = await User.findOne({ email });
  const hash = user?.password || '$2b$12$invalidHashForTimingAttackPrevention';
  const isValid = await bcrypt.compare(password, hash);

  if (!user || !isValid) {
    throw new Error('Invalid credentials'); // Never distinguish "wrong email" from "wrong password"
  }
  return user;
}

JWT Security Best Practices

JSON Web Tokens are widely used but frequently misimplemented. The most common mistakes: storing JWTs in localStorage (vulnerable to XSS), using symmetric secrets that are too short, and setting expiry times in days instead of minutes.

services/token.service.js
const jwt = require('jsonwebtoken');

const ACCESS_TOKEN_SECRET  = process.env.JWT_ACCESS_SECRET;   // Min 32 chars
const REFRESH_TOKEN_SECRET = process.env.JWT_REFRESH_SECRET;  // Different key!

function generateTokens(userId, roles = []) {
  // Short-lived access token stored in memory on the client
  const accessToken = jwt.sign(
    { sub: userId, roles, type: 'access' },
    ACCESS_TOKEN_SECRET,
    { expiresIn: '15m', algorithm: 'HS256' }
  );

  // Longer refresh token stored in httpOnly cookie, never localStorage
  const refreshToken = jwt.sign(
    { sub: userId, type: 'refresh' },
    REFRESH_TOKEN_SECRET,
    { expiresIn: '7d', algorithm: 'HS256' }
  );

  return { accessToken, refreshToken };
}

// Auth middleware
function authenticateJWT(req, res, next) {
  const authHeader = req.headers.authorization;
  const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;

  if (!token) return res.status(401).json({ error: 'Authentication required' });

  try {
    req.user = jwt.verify(token, ACCESS_TOKEN_SECRET, {
      algorithms: ['HS256'],  // Whitelist prevents algorithm confusion attacks
    });
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
    }
    res.status(403).json({ error: 'Invalid token' });
  }
}

module.exports = { generateTokens, authenticateJWT };

Secure Cookie Configuration

Refresh tokens belong in httpOnly cookies, not localStorage. This makes them inaccessible to JavaScript, protecting against XSS-based token theft:

Secure Refresh Token Cookie
res.cookie('refreshToken', refreshToken, {
  httpOnly: true,          // Inaccessible to document.cookie / XSS
  secure: true,            // HTTPS only — never sent over plain HTTP
  sameSite: 'strict',      // Blocks cross-site CSRF requests
  maxAge: 7 * 24 * 60 * 60 * 1000,  // 7 days in ms
  path: '/api/auth/refresh'  // Scope to the refresh endpoint only
});

// Send the short-lived access token in the response body
// Client stores it in memory (JS variable), not localStorage
res.json({ accessToken, user: { id: user.id, email: user.email } });

Defending Against Common Web Attacks

Even with solid authentication, your application remains exposed if you don't defend against injection attacks, cross-site scripting, and CSRF. These vulnerabilities are exploitable regardless of whether an attacker knows user credentials.

SQL Injection Prevention

SQL Injection — Vulnerable vs Secure
// VULNERABLE: String interpolation lets attackers inject arbitrary SQL
// Attacker sends email: ' OR '1'='1
const query = `SELECT * FROM users WHERE email = '${req.body.email}'`;
// Resulting SQL: SELECT * FROM users WHERE email = '' OR '1'='1'  -- returns ALL users!

// SECURE: Parameterized queries with mysql2
const [rows] = await db.execute(
  'SELECT * FROM users WHERE email = ? AND active = ?',
  [req.body.email, true]
);

// SECURE: With pg (PostgreSQL) — $1, $2 placeholders
const { rows } = await pool.query(
  'SELECT id, email, role FROM users WHERE email = $1',
  [req.body.email]
);

// SECURE: Knex.js auto-parameterizes all values
const user = await knex('users')
  .where({ email: req.body.email, active: true })
  .first();

NoSQL Injection Prevention

MongoDB is not immune. If user input reaches a Mongoose query directly, an attacker can send a JSON object with MongoDB operators instead of a string:

NoSQL Injection Prevention
// VULNERABLE: If attacker sends { "username": { "$gt": "" } }
// MongoDB operator "$gt" means greater-than — matches ALL users
User.findOne({ username: req.body.username });

// SECURE: Use express-mongo-sanitize to strip $ and . operators globally
const mongoSanitize = require('express-mongo-sanitize');
app.use(mongoSanitize({ replaceWith: '_' }));

// SECURE: Explicit type enforcement before any DB query
function sanitizeString(input, maxLen = 100) {
  if (typeof input !== 'string') throw new Error('Invalid input type');
  return input.trim().slice(0, maxLen);
}

const username = sanitizeString(req.body.username, 50);
const user = await User.findOne({ username });
Client Request Rate Limiter 429 if exceeded Helmet Headers CSP, HSTS… Auth Middleware JWT verify Input Validate Joi / validator Logic Handler Blocked (429/400/403) Node.js Layered Security Middleware

Every request passes through layered defenses — malicious traffic is rejected at the earliest possible layer

CSRF Protection

Cross-Site Request Forgery tricks authenticated users into making unintended requests from malicious sites. For cookie-based sessions, protect with CSRF tokens. For JWT APIs that send tokens in headers (not cookies), CSRF is generally not a concern since attackers can't read or inject custom headers cross-origin.

CSRF Protection for Session-Based Apps
// Option 1: csurf middleware for form-based apps
const csrf = require('csurf');
const csrfProtection = csrf({ cookie: { httpOnly: true, secure: true } });

app.get('/checkout', csrfProtection, (req, res) => {
  res.render('checkout', { csrfToken: req.csrfToken() });
});

app.post('/checkout', csrfProtection, (req, res) => {
  // Token is verified automatically — safe to proceed
  processOrder(req.body);
});

// Option 2: SameSite cookies (modern browsers, no extra library)
app.use(session({
  secret: process.env.SESSION_SECRET,
  cookie: {
    httpOnly: true,
    secure: true,
    sameSite: 'strict'  // Browser refuses to send cookie on cross-origin requests
  }
}));

HTTP Security Headers with Helmet.js

Helmet.js is a collection of Express middlewares that set HTTP response headers to protect against well-known vulnerabilities. A single line of code eliminates an entire class of attacks—it's the highest-ROI security addition you can make to any Node.js application.

1

Install Helmet

Add it with npm install helmet. It has zero runtime dependencies and adds negligible overhead.

2

Apply Before Any Routes

Call app.use(helmet()) as the first middleware. Security headers must be set before the response is written.

3

Configure Content Security Policy

The default CSP is very restrictive. Customize it for your specific CDN domains and inline styles to avoid breaking legitimate resources.

app.js — Complete Helmet Configuration
const helmet = require('helmet');

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc:  ["'self'", "'strict-dynamic'"],
      styleSrc:   ["'self'", 'https:', "'unsafe-inline'"],
      imgSrc:     ["'self'", 'data:', 'https:'],
      connectSrc: ["'self'"],
      fontSrc:    ["'self'", 'https:', 'data:'],
      objectSrc:  ["'none'"],           // Block Flash, Java applets
      frameAncestors: ["'none'"],       // Prevent clickjacking via iframes
      upgradeInsecureRequests: [],      // Force HTTPS for all subresources
    },
  },
  hsts: {
    maxAge: 31536000,            // 1 year in seconds
    includeSubDomains: true,
    preload: true                // Submit domain to browser HSTS preload list
  },
  referrerPolicy: {
    policy: 'strict-origin-when-cross-origin'
  },
}));
Header Set by Helmet Protects Against
Content-Security-Policy XSS, code injection via unauthorized scripts/styles
Strict-Transport-Security Man-in-the-middle attacks, HTTP downgrade attacks
X-Frame-Options Clickjacking attacks via invisible iframes
X-Content-Type-Options MIME-type sniffing that executes scripts as HTML
Referrer-Policy Sensitive URL leakage in HTTP Referer headers
Permissions-Policy Unauthorized use of camera, microphone, geolocation

Rate Limiting, Dependencies, and Secrets

Even a perfectly secured application can be taken down by brute-force attacks or compromised through a vulnerable npm package. Rate limiting, dependency auditing, and secrets management are the final critical layers of your security posture.

Rate Limiting with express-rate-limit

middleware/rateLimiter.js
const rateLimit = require('express-rate-limit');

// General API rate limit — 100 requests per 15 minutes per IP
const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  standardHeaders: true,   // Return rate info in RateLimit-* headers (RFC 6585)
  legacyHeaders: false,
  message: { error: 'Too many requests. Please wait before trying again.' },
});

// Strict limit for auth endpoints — prevent brute force
const authLimiter = rateLimit({
  windowMs: 60 * 60 * 1000,  // 1 hour window
  max: 10,
  skipSuccessfulRequests: true,  // Only count failed attempts
  message: { error: 'Too many login attempts. Try again in an hour.' },
  keyGenerator: (req) => req.body.email || req.ip,  // Track by email, not just IP
});

// Password reset — very strict (attackers use this for account enumeration)
const resetLimiter = rateLimit({
  windowMs: 24 * 60 * 60 * 1000,  // 24 hours
  max: 3,
  message: { error: 'Password reset limit reached. Try again tomorrow.' },
});

// Apply to routes
app.use('/api', apiLimiter);
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/forgot-password', resetLimiter);

module.exports = { apiLimiter, authLimiter, resetLimiter };

Dependency Security

The npm ecosystem contains over 2 million packages, and malicious or vulnerable dependencies are a growing threat. The event-stream compromise (2018) and ua-parser-js hijacking (2021) showed that even widely-adopted packages can be weaponized by attackers who take over maintainer accounts.

Dependency Security Commands
# Audit current dependencies for known CVEs
npm audit

# Auto-fix vulnerabilities where safe to do so
npm audit fix

# Use npm ci in CI/CD — it's deterministic (uses package-lock.json exactly)
npm ci

# Snyk: deeper analysis, monitoring, and pull request alerts
npm install -g snyk
snyk auth
snyk test              # One-time scan
snyk monitor           # Continuous monitoring of production dependencies

# Lock Node.js version in package.json to prevent runtime surprises
# "engines": { "node": ">=20.0.0 <21.0.0" }

# Use .npmrc to prevent accidental publishing of private packages
# echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc

Secrets Management

Hardcoded credentials are responsible for some of the most avoidable breaches. In 2023, GitHub's secret scanning program detected over 10 million exposed secrets in public repositories. Never commit secrets—validate them at startup instead.

config/env.js — Startup Validation
// .env file — NEVER commit to version control
// DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
// JWT_ACCESS_SECRET=minimum-32-character-random-secret-here
// JWT_REFRESH_SECRET=another-different-32-char-secret-here

// .gitignore — protect your secrets
// .env
// .env.local
// .env.production

// config/env.js — validate at startup, fail fast if misconfigured
const requiredEnvVars = [
  'DATABASE_URL',
  'JWT_ACCESS_SECRET',
  'JWT_REFRESH_SECRET',
  'SESSION_SECRET',
];

function validateEnv() {
  const missing = requiredEnvVars.filter(key => !process.env[key]);
  if (missing.length > 0) {
    console.error('Missing required environment variables:', missing.join(', '));
    process.exit(1);
  }

  // Enforce minimum secret strength
  if (process.env.JWT_ACCESS_SECRET.length < 32) {
    console.error('JWT_ACCESS_SECRET must be at least 32 characters');
    process.exit(1);
  }
}

// Call this before app.listen() — never start with incomplete config
validateEnv();

module.exports = {
  db: { url: process.env.DATABASE_URL },
  jwt: {
    accessSecret: process.env.JWT_ACCESS_SECRET,
    refreshSecret: process.env.JWT_REFRESH_SECRET,
  },
};
Rotate Compromised Secrets Immediately

If a secret is ever exposed—accidental commit, log file leak, error message—treat all tokens signed with that secret as compromised. Rotating the JWT secret automatically invalidates every existing access and refresh token, forcing all users to re-authenticate. Add secret scanning to your CI pipeline with git-secrets or GitHub's push protection to prevent future incidents.

Production Hardening and Error Handling

The final layer of Node.js security is how your application behaves under attack and when things go wrong. A well-hardened production application reveals as little as possible about its internals while logging everything it needs to detect and investigate incidents.

Global Error Handler — Secure Production Setup
// 4-parameter signature tells Express this is an error handler
// Register AFTER all routes
app.use((err, req, res, next) => {
  // Log full error internally — never lose diagnostic context
  logger.error({
    message: err.message,
    stack: err.stack,
    url: req.url,
    method: req.method,
    ip: req.ip,
    userId: req.user?.id,
  });

  // Known operational errors get specific, safe messages
  if (err.name === 'ValidationError') {
    return res.status(400).json({ error: err.message });
  }
  if (err.name === 'UnauthorizedError') {
    return res.status(401).json({ error: 'Authentication required' });
  }

  // Unknown errors: generic response only — no stack traces, no internals
  res.status(err.statusCode || 500).json({
    error: process.env.NODE_ENV === 'production'
      ? 'An internal error occurred'
      : err.message  // Detailed only in development
  });
});

Node.js Security Checklist

  • Input Validation: Every external input validated with Joi or express-validator before business logic
  • Parameterized Queries: Zero string-concatenated SQL or NoSQL queries in the codebase
  • Password Hashing: bcrypt with cost factor ≥12 — never MD5, SHA-1, or plaintext
  • JWT Security: 15-minute access tokens, algorithm whitelist, httpOnly refresh cookies
  • Security Headers: Helmet.js with strict Content-Security-Policy
  • Rate Limiting: Applied to all routes, significantly stricter on auth endpoints
  • Dependency Audits: npm audit in CI, Snyk monitoring in production
  • Secrets Management: Environment variables only, startup validation, documented rotation plan
  • HTTPS Everywhere: HSTS preload header, HTTP → HTTPS redirect at ingress level
  • Error Handling: Stack traces and internal details never reach production clients
"Security is not a product, but a process. Building security into every layer of your Node.js application is not just best practice — it's what separates professional production systems from vulnerable prototypes."

Security in Node.js is never a one-time task. The threat landscape evolves constantly: new CVEs appear in npm packages, new attack patterns emerge, and attackers grow more sophisticated. Schedule quarterly security reviews, subscribe to Node.js security advisories and the npm security feed, and treat each npm audit finding as a production incident until resolved. The applications that survive are those where security is a continuous engineering discipline, not an afterthought added before launch.

Node.js Security Best Practices JWT Helmet.js Rate Limiting XSS SQL Injection
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.