You build an application. It works. Users love it. Then someone finds a vulnerability. SQL injection. Your database wiped. Or XSS attack. User data stolen. Or brute force. Accounts compromised. You didn't know these attacks existed.

You read about security. Too many concepts. SQL injection, XSS, CSRF, session hijacking, clickjacking, DDoS, man-in-the-middle. You don't understand half of them. How do you protect against all this?

You add some security features. Copy code from tutorials. Helmet middleware here. CORS there. Rate limiting somewhere. You don't know if it's enough. You don't know if it's configured correctly. You don't know what you're missing.

Security is not optional. Attackers scan for vulnerabilities constantly. One hole compromises everything. But security doesn't have to be overwhelming. Learn the fundamentals. Apply proven patterns. Protect your application.

QUICK SUMMARY

What You'll Learn: Web application security protects against common attacks. SQL injection uses malicious SQL in inputs; prevent with parameterized queries. XSS injects scripts into pages; prevent with input sanitization and escaping output. CSRF tricks users into unwanted actions; prevent with CSRF tokens. Brute force attacks guess passwords; prevent with rate limiting and account lockout. Always use HTTPS to encrypt data in transit. Hash passwords with bcrypt, never store plaintext. Validate and sanitize all user input. Use Helmet middleware for security headers. Store secrets in environment variables. Implement proper authentication and authorization. Keep dependencies updated. Use CSP (Content Security Policy) to prevent XSS. Enable CORS properly. Log security events. Regular security audits find vulnerabilities.

Why It Matters:

  • Data breaches destroy trust
  • Legal liability for security failures
  • User data must be protected
  • Attacks happen constantly
  • One vulnerability compromises everything
  • Security is everyone's responsibility
  • Prevention cheaper than recovery

Key Concepts:

  • SQL Injection prevention
  • XSS (Cross-Site Scripting) protection
  • CSRF (Cross-Site Request Forgery) tokens
  • Rate limiting and brute force protection
  • HTTPS and SSL/TLS
  • Password security with bcrypt
  • Input validation and sanitization
  • Security headers with Helmet

What You'll Build:

  • Secure authentication system
  • Input validation layer
  • Rate limiting implementation
  • CSRF protection
  • XSS prevention
  • SQL injection prevention
  • Complete security middleware stack

Time Investment: Understanding basics takes 2–3 hours. Implementing full security is ongoing.

Prerequisites:

  • Express.js (Post 38)
  • Middleware basics (Post 40)
  • Authentication (Posts 47–48)
  • Database operations

FULL DETAILED ARTICLE

OWASP Top 10

The most critical web security risks:

1. Broken Access Control
2. Cryptographic Failures
3. Injection (SQL, NoSQL, Command)
4. Insecure Design
5. Security Misconfiguration
6. Vulnerable and Outdated Components
7. Identification and Authentication Failures
8. Software and Data Integrity Failures
9. Security Logging and Monitoring Failures
10. Server-Side Request Forgery (SSRF)

We'll cover the most critical ones for Node.js applications.

SQL Injection Prevention

Attack example:

// VULNERABLE - Never do this
const email = req.body.email;
const query = `SELECT * FROM users WHERE email = '${email}'`;
const user = await db.query(query);
// Attacker sends: admin'--
// Query becomes: SELECT * FROM users WHERE email = 'admin'--'
// The -- comments out the rest, bypassing authentication

Prevention with parameterized queries:

// Safe - parameterized query
const email = req.body.email;
const query = 'SELECT * FROM users WHERE email = $1';
const user = await db.query(query, [email]);
// Or with Mongoose
const user = await User.findOne({ email: req.body.email });
// Or with Prisma
const user = await prisma.user.findUnique({
  where: { email: req.body.email }
});

NoSQL injection prevention:

// Vulnerable
const user = await User.findOne({
  email: req.body.email,
  password: req.body.password
});
// Attacker sends: { "password": { "$ne": null } }
// Finds user with any password
// Safe - validate input types
const { email, password } = req.body;
if (typeof email !== 'string' || typeof password !== 'string') {
  return res.status(400).json({ error: 'Invalid input' });
}
const user = await User.findOne({ email }).select('+password');
if (!user || !await user.comparePassword(password)) {
  return res.status(401).json({ error: 'Invalid credentials' });
}

XSS (Cross-Site Scripting) Prevention

Attack example:

// Vulnerable - renders user input directly
app.get('/search', (req, res) => {
  const query = req.query.q;
  res.send(`<h1>Search results for: ${query}</h1>`);
});
// Attacker visits: /search?q=<script>alert(document.cookie)</script>
// Script executes, steals cookies

Prevention:

// 1. Escape output (template engines do this)
app.set('view engine', 'ejs');
app.get('/search', (req, res) => {
  const query = req.query.q;
  res.render('search', { query });  // EJS automatically escapes
});
// 2. Sanitize input
const sanitizeHtml = require('sanitize-html');
app.post('/comment', (req, res) => {
  const clean = sanitizeHtml(req.body.comment, {
    allowedTags: ['b', 'i', 'em', 'strong'],
    allowedAttributes: {}
  });
  
  await Comment.create({ text: clean });
  res.json({ success: true });
});
// 3. Content Security Policy
const helmet = require('helmet');
app.use(
  helmet.contentSecurityPolicy({
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", 'data:', 'https:'],
    }
  })
);
// 4. Set X-XSS-Protection header
app.use(helmet.xssFilter());

React automatically escapes:

// Safe in React - automatically escaped
function SearchResults({ query }) {
  return <h1>Search results for: {query}</h1>;
}
// Dangerous - only use if you trust the content
function DangerousHTML({ html }) {
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

CSRF (Cross-Site Request Forgery) Prevention

Attack example:

<!-- Attacker's website -->
<img src="http://yourbank.com/transfer?to=attacker&amount=1000" />
<!-- If user is logged in, this executes the transfer -->

Prevention with CSRF tokens:

const csrf = require('csurf');
const cookieParser = require('cookie-parser');
app.use(cookieParser());
// CSRF protection
const csrfProtection = csrf({ cookie: true });
// Send CSRF token to client
app.get('/form', csrfProtection, (req, res) => {
  res.render('form', { csrfToken: req.csrfToken() });
});
// Validate CSRF token on POST
app.post('/transfer', csrfProtection, (req, res) => {
  // Token automatically validated
  // If invalid, error thrown
  performTransfer(req.body);
  res.json({ success: true });
});
// HTML form
/*
<form method="POST" action="/transfer">
  <input type="hidden" name="_csrf" value="<%= csrfToken %>" />
  <input type="text" name="amount" />
  <button type="submit">Transfer</button>
</form>
*/

For APIs (use SameSite cookies):

// Set SameSite attribute on cookies
res.cookie('token', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict'  // Prevents CSRF
});

Rate Limiting

Prevent brute force and DoS attacks:

const rateLimit = require('express-rate-limit');
// General rate limiting
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 100,  // 100 requests per window
  message: 'Too many requests, please try again later',
  standardHeaders: true,
  legacyHeaders: false,
});
app.use('/api', limiter);
// Strict limiting for auth endpoints
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,  // Only 5 login attempts per 15 minutes
  skipSuccessfulRequests: true,
  message: 'Too many login attempts, please try again later'
});
app.post('/api/auth/login', authLimiter, login);
// Per-user rate limiting
const Redis = require('ioredis');
const redis = new Redis();
async function userRateLimit(req, res, next) {
  const userId = req.user?.id || req.ip;
  const key = `rate:${userId}`;
  
  const requests = await redis.incr(key);
  
  if (requests === 1) {
    await redis.expire(key, 60);  // 1 minute window
  }
  
  if (requests > 10) {
    return res.status(429).json({ error: 'Rate limit exceeded' });
  }
  
  next();
}
app.use('/api', userRateLimit);

Account lockout after failed attempts:

app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  
  const user = await User.findOne({ email }).select('+password +loginAttempts +lockUntil');
  
  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  
  // Check if account is locked
  if (user.lockUntil && user.lockUntil > Date.now()) {
    return res.status(423).json({ error: 'Account temporarily locked' });
  }
  
  // Check password
  const isMatch = await user.comparePassword(password);
  
  if (!isMatch) {
    // Increment failed attempts
    user.loginAttempts = (user.loginAttempts || 0) + 1;
    
    // Lock account after 5 failed attempts
    if (user.loginAttempts >= 5) {
      user.lockUntil = new Date(Date.now() + 15 * 60 * 1000);  // 15 minutes
    }
    
    await user.save();
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  
  // Reset attempts on successful login
  user.loginAttempts = 0;
  user.lockUntil = undefined;
  await user.save();
  
  // Generate token...
  res.json({ user, token });
});

Security Headers with Helmet

const helmet = require('helmet');
// Use all default protections
app.use(helmet());
// Or configure individually
app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'", "'unsafe-inline'"],
    styleSrc: ["'self'", "'unsafe-inline'"],
    imgSrc: ["'self'", 'data:', 'https:'],
    connectSrc: ["'self'"],
    fontSrc: ["'self'"],
    objectSrc: ["'none'"],
    mediaSrc: ["'self'"],
    frameSrc: ["'none'"],
  }
}));
app.use(helmet.dnsPrefetchControl());
app.use(helmet.frameguard({ action: 'deny' }));
app.use(helmet.hidePoweredBy());
app.use(helmet.hsts({ maxAge: 31536000 }));
app.use(helmet.ieNoOpen());
app.use(helmet.noSniff());
app.use(helmet.permittedCrossDomainPolicies());
app.use(helmet.referrerPolicy({ policy: 'no-referrer' }));
app.use(helmet.xssFilter());

Headers explained:

X-Content-Type-Options: nosniff
  Prevents MIME type sniffing
X-Frame-Options: DENY
  Prevents clickjacking
Strict-Transport-Security: max-age=31536000
  Forces HTTPS
X-XSS-Protection: 1; mode=block
  Enables browser XSS protection
Content-Security-Policy: default-src 'self'
  Controls resource loading
Referrer-Policy: no-referrer
  Controls referrer information

HTTPS and SSL/TLS

Always use HTTPS in production:

// Development with self-signed certificate
const https = require('https');
const fs = require('fs');
const options = {
  key: fs.readFileSync('key.pem'),
  cert: fs.readFileSync('cert.pem')
};
https.createServer(options, app).listen(443);
// Production with Let's Encrypt
// Use reverse proxy (nginx) for SSL termination

Force HTTPS:

// Redirect HTTP to HTTPS
app.use((req, res, next) => {
  if (req.header('x-forwarded-proto') !== 'https' && process.env.NODE_ENV === 'production') {
    res.redirect(`https://${req.header('host')}${req.url}`);
  } else {
    next();
  }
});

Secure cookies (HTTPS only):

res.cookie('token', token, {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',  // HTTPS only
  sameSite: 'strict',
  maxAge: 7 * 24 * 60 * 60 * 1000
});

Input Validation and Sanitization

Validate all user input:

const { z } = require('zod');
const registerSchema = z.object({
  email: z.string().email().max(255),
  password: z.string().min(8).max(100),
  name: z.string().min(2).max(50).trim()
});
app.post('/register', async (req, res) => {
  try {
    const validated = registerSchema.parse(req.body);
    
    // Use validated data
    const user = await User.create(validated);
    res.json({ user });
  } catch (err) {
    if (err instanceof z.ZodError) {
      return res.status(422).json({ errors: err.errors });
    }
    throw err;
  }
});

Sanitize HTML:

const sanitizeHtml = require('sanitize-html');
function sanitizeUserInput(html) {
  return sanitizeHtml(html, {
    allowedTags: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
    allowedAttributes: {
      'a': ['href']
    },
    allowedSchemes: ['http', 'https', 'mailto']
  });
}
app.post('/comment', (req, res) => {
  const clean = sanitizeUserInput(req.body.comment);
  // Save clean comment
});

Prevent path traversal:

const path = require('path');
app.get('/file/:filename', (req, res) => {
  const filename = req.params.filename;
  
  // Prevent directory traversal
  const safePath = path.normalize(filename).replace(/^(\.\.(\/|\\|$))+/, '');
  const filepath = path.join(__dirname, 'uploads', safePath);
  
  // Check if file is in allowed directory
  if (!filepath.startsWith(path.join(__dirname, 'uploads'))) {
    return res.status(403).json({ error: 'Access denied' });
  }
  
  res.sendFile(filepath);
});

Password Security

Best practices:

const bcrypt = require('bcrypt');
// Hash password with high cost factor
async function hashPassword(password) {
  const salt = await bcrypt.genSalt(12);  // 12 rounds
  return await bcrypt.hash(password, salt);
}
// Password requirements
function validatePassword(password) {
  const minLength = 8;
  const maxLength = 128;
  
  if (password.length < minLength || password.length > maxLength) {
    return { valid: false, error: `Password must be ${minLength}-${maxLength} characters` };
  }
  
  const hasUpperCase = /[A-Z]/.test(password);
  const hasLowerCase = /[a-z]/.test(password);
  const hasNumbers = /\d/.test(password);
  const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
  
  if (!hasUpperCase || !hasLowerCase || !hasNumbers || !hasSpecialChar) {
    return {
      valid: false,
      error: 'Password must contain uppercase, lowercase, number, and special character'
    };
  }
  
  return { valid: true };
}
// Check against common passwords
const commonPasswords = new Set([
  'password', '123456', 'qwerty', 'admin', 'letmein'
  // ... add more
]);
if (commonPasswords.has(password.toLowerCase())) {
  return res.status(400).json({ error: 'Password too common' });
}

Password reset security:

const crypto = require('crypto');
// Generate secure reset token
function generateResetToken() {
  return crypto.randomBytes(32).toString('hex');
}
// Hash token before storing
function hashToken(token) {
  return crypto.createHash('sha256').update(token).digest('hex');
}
// Password reset request
app.post('/forgot-password', async (req, res) => {
  const user = await User.findOne({ email: req.body.email });
  
  if (!user) {
    // Don't reveal if email exists
    return res.json({ message: 'If email exists, reset link sent' });
  }
  
  const resetToken = generateResetToken();
  const hashedToken = hashToken(resetToken);
  
  user.resetPasswordToken = hashedToken;
  user.resetPasswordExpires = Date.now() + 3600000;  // 1 hour
  await user.save();
  
  // Send email with reset link containing unhashed token
  await sendEmail({
    to: user.email,
    subject: 'Password Reset',
    text: `Reset link: https://example.com/reset/${resetToken}`
  });
  
  res.json({ message: 'If email exists, reset link sent' });
});
// Reset password
app.post('/reset-password/:token', async (req, res) => {
  const hashedToken = hashToken(req.params.token);
  
  const user = await User.findOne({
    resetPasswordToken: hashedToken,
    resetPasswordExpires: { $gt: Date.now() }
  });
  
  if (!user) {
    return res.status(400).json({ error: 'Invalid or expired token' });
  }
  
  // Validate new password
  const validation = validatePassword(req.body.password);
  if (!validation.valid) {
    return res.status(400).json({ error: validation.error });
  }
  
  user.password = await hashPassword(req.body.password);
  user.resetPasswordToken = undefined;
  user.resetPasswordExpires = undefined;
  await user.save();
  
  res.json({ message: 'Password reset successful' });
});

Environment Variables and Secrets

Never commit secrets:

// .env (add to .gitignore)
DATABASE_URL=mongodb://localhost:27017/myapp
JWT_SECRET=your-secret-key-here
CLOUDINARY_API_KEY=your-api-key
STRIPE_SECRET_KEY=sk_test_...
// Load with dotenv
require('dotenv').config();
const secret = process.env.JWT_SECRET;
// Validate required secrets
const requiredEnvVars = [
  'DATABASE_URL',
  'JWT_SECRET',
  'JWT_REFRESH_SECRET'
];
for (const envVar of requiredEnvVars) {
  if (!process.env[envVar]) {
    console.error(`Missing required environment variable: ${envVar}`);
    process.exit(1);
  }
}

Dependency Security

Keep dependencies updated:

# Check for vulnerabilities
npm audit
# Fix vulnerabilities
npm audit fix
# Check for outdated packages
npm outdated
# Update packages
npm update
# Use Snyk for continuous monitoring
npm install -g snyk
snyk test
snyk monitor

Lock dependency versions:

{
  "dependencies": {
    "express": "4.18.2",  // Exact version, not ^4.18.2
    "mongoose": "7.0.3"
  }
}

Security Logging

Log security events:

const winston = require('winston');
const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    new winston.transports.File({ filename: 'error.log', level: 'error' }),
    new winston.transports.File({ filename: 'security.log', level: 'warn' })
  ]
});
// Log failed login attempts
app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  
  const user = await User.findOne({ email });
  
  if (!user || !await user.comparePassword(password)) {
    logger.warn('Failed login attempt', {
      email,
      ip: req.ip,
      userAgent: req.headers['user-agent'],
      timestamp: new Date()
    });
    
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  
  logger.info('Successful login', {
    userId: user.id,
    ip: req.ip,
    timestamp: new Date()
  });
  
  // Generate token...
});
// Log suspicious activity
function detectSuspiciousActivity(req) {
  // Multiple failed attempts
  // Unusual access patterns
  // Access from new location
  // etc.
}

Complete Security Middleware Stack

const express = require('express');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const mongoSanitize = require('express-mongo-sanitize');
const hpp = require('hpp');
const cors = require('cors');
const app = express();
// Security headers
app.use(helmet());
// CORS
app.use(cors({
  origin: process.env.ALLOWED_ORIGINS?.split(',') || 'http://localhost:3000',
  credentials: true
}));
// Rate limiting
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100
});
app.use('/api', limiter);
// Body parsing
app.use(express.json({ limit: '10kb' }));  // Limit body size
app.use(express.urlencoded({ extended: true, limit: '10kb' }));
// Data sanitization against NoSQL injection
app.use(mongoSanitize());
// Prevent HTTP Parameter Pollution
app.use(hpp());
// Force HTTPS
if (process.env.NODE_ENV === 'production') {
  app.use((req, res, next) => {
    if (req.header('x-forwarded-proto') !== 'https') {
      res.redirect(`https://${req.header('host')}${req.url}`);
    } else {
      next();
    }
  });
}
// Your routes
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
// Error handler
app.use((err, req, res, next) => {
  // Don't leak error details in production
  const message = process.env.NODE_ENV === 'production' 
    ? 'Something went wrong' 
    : err.message;
  
  res.status(err.statusCode || 500).json({ error: message });
});
module.exports = app;

Summary: Your Security Checklist

Injection Protection:

  • Use parameterized queries
  • Validate input types
  • Sanitize user input
  • Escape output

XSS Prevention:

  • Escape all user content
  • Use Content Security Policy
  • Sanitize HTML input
  • Use template engines that escape

CSRF Protection:

  • Use CSRF tokens
  • SameSite cookies
  • Verify origin headers

Authentication:

  • Hash passwords with bcrypt
  • Use strong JWT secrets
  • Implement rate limiting
  • Account lockout after failures
  • Secure password reset

General:

  • Use HTTPS everywhere
  • Security headers with Helmet
  • Validate all input
  • Keep dependencies updated
  • Log security events
  • Regular security audits

Practice Challenges

Secure these scenarios:

Challenge 1: API Security Audit Review and secure an existing API.

Challenge 2: Penetration Testing Try to break your own application.

Challenge 3: Security Headers Configure CSP and all security headers.

Challenge 4: Input Validation Build comprehensive validation for all endpoints.

Challenge 5: Security Monitoring Implement logging and alerting for suspicious activity.

You are 50 posts into the 75-post series. Security best practices are now in your toolkit.