Security

Securing Node.js Applications

Mayur Dabhi
Mayur Dabhi
April 29, 2026
15 min read

A fresh Express app does almost nothing to protect you. There's no CSRF protection, no security headers, no rate limit on the login route, and it happily announces X-Powered-By: Express to anyone who asks. Frameworks like Laravel or Rails ship with a lot of that switched on. In Node, every one of those decisions is yours, and the ones you forget about are the ones that bite.

This post walks through what I'd put in place before a Node.js API goes to production: input validation, password and token handling, injection and CSRF defenses, Helmet, rate limiting, dependency and secrets hygiene, and error handling that doesn't leak your internals.

Why APIs Get Targeted

Web application attacks show up year after year as one of the top breach patterns in Verizon's Data Breach Investigations Report. A typical Node.js backend holds auth tokens, personal data and sometimes payment details, so it's exactly the kind of thing those attacks go after. Treat security work as part of shipping the feature, not a separate ticket for later.

What You're Actually Defending Against

Most of the risks are the usual OWASP Top 10 suspects. On top of those, Node adds a few of its own, mostly because of JavaScript's dynamic objects and how deep the npm dependency tree goes.

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 to Critical npm audit, Snyk, automated updates
Secrets Exposure Critical Environment variables, secrets management
Rate Limiting Bypass Medium express-rate-limit, IP-based throttling

There's one more Node-specific problem: everything runs on a single-threaded event loop. One unhandled promise rejection can crash the process, and one badly written regex fed a nasty input (ReDoS, regular expression denial of service) can block every other request while it backtracks.

Input Validation and Sanitization

Start with the boring rule: never trust user input. Request bodies, query parameters, headers and cookies all come from the client, and all of them should be checked against a strict schema before they reach your business logic or database.

Validating with express-validator

express-validator wraps validator.js in chainable middleware, so the rules sit right next to the route they protect.

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

For nested request bodies I prefer Joi. You describe the whole shape once, including limits like "at most 50 items", and validate it in one go:

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. It drops any field the client sends that isn't in your schema. Without it, someone can add isAdmin: true to a request body, and if that body goes straight into your ORM's create or update call, you've got a mass assignment bug.

Securing Authentication and Sessions

The auth bugs I run into are rarely exotic. It's usually passwords hashed with the wrong algorithm, tokens stored somewhere JavaScript can read them, or a login route with no brute-force protection at all.

Password Hashing with bcrypt

Don't store passwords in plaintext or hash them with MD5 or SHA-1. Those algorithms are built to be fast, and fast is exactly what an attacker with a GPU wants. bcrypt is deliberately slow and salts every password for you.

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, so it's 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 the 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;
}
bcrypt Ignores Everything After 72 Bytes

bcrypt only looks at the first 72 bytes of the input. Two long passphrases that share the same first 72 bytes will hash to the same thing, and multi-byte characters eat into that limit faster than you'd expect. Put a sensible max length on the password field (validation is a good place for it), or switch to Argon2id if you need to support very long passphrases.

JWT Security Best Practices

JWTs get implemented badly all the time. The usual mistakes: keeping them in localStorage where any XSS can read them, signing with a short secret, and setting expiry in days when it should be 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

Put refresh tokens in httpOnly cookies, not localStorage. JavaScript can't read them there, so an XSS bug can't simply grab the token and send it off:

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

Good auth doesn't help much if someone can inject SQL, run scripts in your users' browsers, or forge requests on their behalf. None of these attacks need a valid password.

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), using $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

Using MongoDB doesn't get you off the hook. If user input goes straight into a Mongoose query, an attacker can send a JSON object full of query operators where you expected a string:

NoSQL Injection Prevention
// VULNERABLE: If attacker sends { "username": { "$gt": "" } }
// MongoDB operator "$gt" means greater-than, so this 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

Each request passes through the layers in order, and bad traffic gets rejected at the first one that catches it

CSRF Protection

CSRF works by getting a logged-in user's browser to send a request from some other site, with their cookies attached. If you use cookie-based sessions, you need CSRF tokens or SameSite cookies. If your API takes a JWT in the Authorization header instead, CSRF mostly isn't a concern, because another origin can't set that header on the user's behalf.

csurf Is Deprecated

The csurf package below was deprecated by the Express team in 2022 and isn't maintained anymore. It still works for existing apps, but for new projects look at a maintained alternative such as csrf-csrf (double-submit cookie pattern), or rely on sameSite cookies plus an Origin header check.

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, so it's 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

If you only do one thing from this post today, add Helmet. It's one line, and it sets a bunch of response headers (CSP, HSTS, frame options and more) that block or limit well-known browser attacks. For the effort involved, nothing else here comes close.

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 strict. Add your CDN domains and any inline styles you really need, or your own scripts and fonts will stop loading. The browser console will tell you exactly which directive blocked what.

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

You can get everything above right and still get hurt by a credential-stuffing script hammering your login route, a compromised npm package, or a database password sitting in your git history.

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 to 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 };
Behind a Proxy? Set trust proxy

If the app runs behind Nginx, a load balancer, or Cloudflare, req.ip is the proxy's address unless you call app.set('trust proxy', 1) (or the number of hops you actually have). Skip it and every visitor shares one rate limit bucket, so a single busy user can lock everyone out. Recent versions of express-rate-limit catch this and log an ERR_ERL_UNEXPECTED_X_FORWARDED_FOR validation error. Don't "fix" it with trust proxy: true on a box that's directly reachable, though, or clients can spoof their IP through the header.

Dependency Security

A normal Express project pulls in hundreds of transitive packages, and any of them can turn malicious. The event-stream compromise (2018) and the ua-parser-js hijacking (2021) both involved popular packages where an attacker got publish access and shipped malware to everyone who updated.

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 cause some of the most avoidable breaches there are. Secret scanners find API keys and database URLs in public repos constantly, and bots scrape GitHub for them within minutes of a push. Keep secrets out of the code, and check at startup that the ones you need are actually set.

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 leaks (an accidental commit, a log file, an error message), assume every token signed with it is compromised. Rotating the JWT secret invalidates all existing access and refresh tokens, so every user has to log in again. That's annoying, but it's the right call. Deleting the commit isn't enough either, since the secret is still in git history and in any clone or fork. To catch the next one before it lands, add git-secrets or GitHub's push protection to your workflow.

Production Hardening and Error Handling

In production you want the app to tell attackers as little as possible about itself, while logging enough that you can work out what happened afterwards.

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 the full error internally so you 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
  });
});

One more thing the handler above doesn't cover: errors thrown outside a request. Add process.on('unhandledRejection') and process.on('uncaughtException') handlers that log the error and then exit, and let PM2 or systemd restart the process. Trying to keep running after an uncaught exception leaves the app in an unknown state, which is worse than a quick restart.

What I Check Before Going to Production

Pre-Deploy Checks

  • Input Validation: Every external input goes through Joi or express-validator before business logic
  • Parameterized Queries: No string-concatenated SQL or NoSQL queries anywhere 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 a strict Content-Security-Policy
  • Rate Limiting: On all routes, much stricter on auth endpoints, with trust proxy set correctly
  • Dependency Audits: npm audit in CI, Snyk monitoring in production
  • Secrets Management: Environment variables only, validated at startup, with a written rotation plan
  • HTTPS Everywhere: HSTS header, HTTP → HTTPS redirect at the ingress
  • Error Handling: Stack traces and internal details never reach production clients

Not all of this is worth doing on day one, though. Some trade-offs I'd think about:

None of this is a one-off job. Subscribe to the Node.js security releases, keep dependencies moving with something like Dependabot or Renovate, and put a recurring review on the calendar so security doesn't only get looked at the week 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.