DEV Community

Alex Chen
Alex Chen

Posted on

Web Security: OWASP Top 10 and How to Fix Them (2026)

Web Security: OWASP Top 10 and How to Fix Them (2026)

Security isn't a feature you add later — it's built into every layer. Here's how the top 10 vulnerabilities work and how to prevent them.

#1 Broken Access Control

// ❌ Vulnerable: User can access anyone's data
app.get('/api/users/:id', (req, res) => {
  const user = await db.users.findById(req.params.id);
  res.json(user); // No check if requester owns this data!
});

// ✅ Secure: Always verify ownership
app.get('/api/users/:id', async (req, res) => {
  // Check: Is the logged-in user requesting their OWN data?
  if (req.params.id !== req.user.id && req.user.role !== 'admin') {
    return res.status(403).json({ error: 'Access denied' });
  }

  const user = await db.users.findById(req.params.id);
  res.json(user);
});

// ✅ Better: Use middleware for all protected routes
const requireOwnership = (resourceType) => async (req, res, next) => {
  const resource = await db[resourceType].findById(req.params.id);
  if (!resource) return res.status(404).json({ error: 'Not found' });

  if (resource.userId !== req.user.id && req.user.role !== 'admin') {
    return res.status(403).json({ error: 'Access denied' });
  }

  req.resource = resource; // Attach for route handler
  next();
};

app.get('/api/posts/:id', auth, requireOwnership('posts'), (req, res) => {
  res.json(req.resource);
});
Enter fullscreen mode Exit fullscreen mode

#2 Cryptographic Failures

// ❌ Storing passwords in plain text or weak hashing
const password = "password123";
db.users.insert({ email, password }); // NEVER DO THIS!

// ✅ Proper password hashing with bcrypt
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12; // Higher = slower = more secure (12 is good balance)

async function hashPassword(password) {
  return bcrypt.hash(password, SALT_ROUNDS);
}

async function comparePassword(password, hash) {
  return bcrypt.compare(password, hash); // Handles salt automatically
}

// Usage:
const hashedPassword = await hashPassword("user_password");
// $2b$12$N9qo8uLOickG2SODUUS... (60 chars, includes salt + hash)

const isValid = await comparePassword(input, hashedPassword);

// ❌ Using MD5/SHA1 for passwords (too fast, vulnerable to rainbow tables)
const md5Hash = crypto.createHash('md5').update(password).digest('hex');
// Cracked in milliseconds with rainbow tables

// ✅ For data encryption (not passwords!):
const crypto = require('crypto');

function encrypt(text, key) {
  const iv = crypto.randomBytes(16); // Unique IV per encryption!
  const cipher = crypto.createCipheriv('aes-256-gcm', Buffer.from(key), iv);
  let encrypted = cipher.update(text, 'utf8', 'hex');
  encrypted += cipher.final('hex');
  const authTag = cipher.getAuthTag();
  return { encrypted, iv: iv.toString('hex'), authTag: authTag.toString('hex') };
}

function decrypt(encryptedData, key) {
  const decipher = crypto.createDecipheriv(
    'aes-256-gcm',
    Buffer.from(key),
    Buffer.from(encryptedData.iv, 'hex')
  );
  decipher.setAuthTag(Buffer.from(encryptedData.authTag, 'hex'));
  let decrypted = decipher.update(encryptedData.encrypted, 'hex', 'utf8');
  decrypted += decipher.final('utf8');
  return decrypted;
}

// ⚠️ Key management:
// - Never hardcode keys in source code!
// - Use environment variables or secrets manager (AWS KMS, HashiCorp Vault)
// - Rotate keys regularly
Enter fullscreen mode Exit fullscreen mode

#3 Injection (SQL, NoSQL, Command)

// === SQL Injection ===
// ❌ String concatenation in queries
app.get('/search', (req, res) => {
  db.query(`SELECT * FROM products WHERE name LIKE '%${req.query.q}%'`);
  // Attacker sends q = "'; DROP TABLE users; --"
  // Result: SELECT * FROM products WHERE name LIKE ''; DROP TABLE users; --'
});

// ✅ Parameterized queries (always!)
app.get('/search', async (req, res) => {
  const results = await db.query(
    'SELECT * FROM products WHERE name LIKE ?',
    [`%${req.query.q}%`]  // Safely escaped by driver
  );
  res.json(results);
});

// With ORM (even safer):
const results = await Product.findAll({
  where: {
    name: { [Op.like]: `%${req.query.q}%` }
  }
});

// === NoSQL Injection ===
// ❌ Passing user input directly to MongoDB query
app.post('/login', async (req, res) => {
  const user = await db.collection('users').findOne({
    username: req.body.username,
    password: req.body.password
  });
  // Attacker sends: username: {"$gt": ""}, password: {"$gt": ""}
  // Matches ANY document!

  // ✅ Use strict equality checks
  const user = await db.collection('users').findOne({
    username: req.body.username,
    password: req.body.password
  }, {
    // Disable operators that could be exploited
    sanitizeFilter: true  // MongoDB option
  });

  // Or use a library like mongo-sanitize:
  const sanitize = require('mongo-sanitize');
  const cleanInput = sanitize(req.body);
});

// === Command Injection ===
// ❌ Running shell commands with user input
const { exec } = require('child_process');
app.get('/ping', (req, res) => {
  exec(`ping -c 4 ${req.body.ip}`, (err, stdout) => {
    res.send(stdout);
  });
  // Attacker sends ip = "127.0.0.1; rm -rf /"
  // Executes BOTH commands!
});

// ✅ Validate input strictly (allowlist approach)
const VALID_IP_REGEX = /^(\d{1,3}\.){3}\d{1,3}$/;

app.get('/ping', (req, res) => {
  if (!VALID_IP_REGEX.test(req.body.ip)) {
    return res.status(400).json({ error: 'Invalid IP address' });
  }
  exec(`ping -c 4 ${req.body.ip}`, (err, stdout) => {
    res.send(stdout);
  });
});
Enter fullscreen mode Exit fullscreen mode

#4 Insecure Design

// ❌ Design flaw: Password reset token is predictable
function generateResetToken() {
  return Math.random().toString(36).substring(7); // Weak randomness!
}

// ✅ Design fix: Use cryptographically secure random tokens
const crypto = require('crypto');

function generateResetToken() {
  return crypto.randomBytes(32).toString('hex'); // 64 hex chars, unpredictable
}

// Store with expiry:
await db.passwordResets.insert({
  userId,
  token: generateResetToken(),
  expiresAt: Date.now() + 15 * 60 * 1000, // 15 minutes
  used: false
});

// ❌ Design flaw: Rate limiting on client side only
// <button onclick="submitForm()">Submit</button>
// <script>let clicks=0; function submitForm(){if(clicks<5){clicks++; ...}}</script>
// Attacker just ignores JavaScript and sends unlimited requests.

// ✅ Server-side rate limiting:
const rateLimit = require('express-rate-limit');

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5,                    // Max 5 attempts per IP
  message: { error: 'Too many attempts. Try again in 15 minutes.' },
  standardHeaders: true,
  legacyHeaders: false,
});
app.post('/api/login', loginLimiter, handleLogin);

// ❌ Design flaw: No account lockout after failed attempts
// Brute force can try billions of combinations over time

// ✅ Account lockout mechanism:
async function handleFailedLogin(userId) {
  const attempts = await incrementLoginAttempts(userId);

  if (attempts >= 5) {
    await lockAccount(userId, 30 * 60 * 1000); // Lock for 30 minutes
    sendSecurityEmail(userId, 'Account locked due to suspicious activity');
  }
}
Enter fullscreen mode Exit fullscreen mode

#5 Security Misconfiguration

// ❌ Exposing stack traces in production
app.use((err, req, res, next) => {
  res.status(500).json({ error: err.message, stack: err.stack });
  // Reveals file paths, library versions, internal structure!

  // ✅ Generic error in production, details in logs
  const isDev = process.env.NODE_ENV !== 'production';
  res.status(err.statusCode || 500).json({
    error: isDev ? err.message : 'Internal server error',
    ...(isDev && { stack: err.stack }),
    incidentId: generateIncidentId(), // For support lookup
  });
  logger.error(`[${incidentId}]`, err.stack); // Details go to logs only
});

// ❌ Missing security headers
// Without headers: Clickjacking possible, MIME sniffing, etc.

// ✅ Add security headers with Helmet:
const helmet = require('helmet');
app.use(helmet());
// Adds:
// X-Frame-Options: DENY (prevents clickjacking)
// X-Content-Type-Options: nosniff (prevents MIME sniffing)
// X-XSS-Protection: mode=block
// Referrer-Policy: strict-origin-when-cross-origin
// Content-Security-Policy (configurable)
// Strict-Transport-Security (HTTPS enforcement)

// Custom CSP configuration:
app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'", "cdn.example.com"],
    styleSrc: ["'self'", "'unsafe-inline'"], // Needed for some CSS frameworks
    imgSrc: ["'self'", "data:", "https:"],
    fontSrc: ["'self'"],
    connectSrc: ["'self'", "api.example.com"],
    frameAncestors: ["'none'"], // Prevents clickjacking
    formAction: ["'self'"],
    upgradeInsecureRequests: [], // Force HTTPS
  },
}));

// ❌ Debug endpoints left enabled in production
if (process.env.DEBUG) {
  app.get('/debug/routes', (req, res) => res.json(app._router.stack));
  app.get('/debug/env', (req, res) => res.json(process.env));
}
// Forgets to disable DEBUG in production → leaks everything!

// ✅ Environment-based feature flags:
const debugRoutes = express.Router();
debugRoutes.get('/routes', (req, res) => res.json(app._router.stack));

if (process.env.NODE_ENV === 'development') {
  app.use('/debug', debugRoutes);
}
// Production simply doesn't mount these routes at all.
Enter fullscreen mode Exit fullscreen mode

#6 Vulnerable & Outdated Components

# The problem: Dependencies have known vulnerabilities
# Attackers scan for outdated packages with public CVEs

# Check your dependencies:
npm audit              # Shows known vulnerabilities
npm audit --fix        # Auto-fixes where possible (patch/minor updates)

# Automated scanning in CI:
# .github/workflows/security.yml
name: Security Audit
on: [push, pull_request]
jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm audit --audit-level=moderate
      # Fails CI if moderate+ severity vulns found

# Lockfile integrity:
npm ci               # Uses package-lock.json exactly (reproducible installs)
# vs npm install     # May update versions (unpredictable!)

# Regular dependency updates:
# Use Dependabot (GitHub native), Renovate, or Snyk
# Set up weekly PRs for dependency updates
Enter fullscreen mode Exit fullscreen mode

#7 Authentication Failures

// ❌ Weak session management
app.post('/login', (req, res) => {
  const token = jwt.sign({ userId: user.id }, SECRET_KEY);
  res.json({ token }); // Never expires!
});

// ✅ Secure JWT implementation
const jwt = require('jsonwebtoken');

function generateTokens(user) {
  const accessToken = jwt.sign(
    { userId: user.id, role: user.role },
    process.env.JWT_SECRET,
    { expiresIn: '15m' } // Short-lived access token
  );

  const refreshToken = crypto.randomBytes(40).toString('hex');
  // Store refresh token in DB with expiry
  await db.refreshTokens.insert({
    token: refreshToken,
    userId: user.id,
    expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 days
  });

  return { accessToken, refreshToken };
}

// Token refresh endpoint:
app.post('/refresh', async (req, res) => {
  const { refreshToken } = req.body;
  const stored = await db.refreshTokens.findOne({ token: refreshToken });

  if (!stored || stored.expiresAt < Date.now() || stored.revoked) {
    return res.status(401).json({ error: 'Invalid or expired refresh token' });
  }

  const user = await db.users.findById(stored.userId);
  const tokens = generateTokens(user);

  // Revoke old refresh token
  await db.refreshTokens.update(stored.id, { revoked: true });

  res.json(tokens);
});

// ❌ Allowing weak passwords
if (password.length >= 6) accept(); // Too short!

// ✅ Password strength requirements:
function validatePasswordStrength(password) {
  const issues = [];
  if (password.length < 12) issues.push('At least 12 characters');
  if (!/[a-z]/.test(password)) issues.push('One lowercase letter');
  if (!/[A-Z]/.test(password)) issues.push('One uppercase letter');
  if (!/\d/.test(password)) issues.push('One number');
  if (!/[!@#$%^&*]/.test(password)) issues.push('One special character');

  // Also check against common/breached passwords
  if (COMMON_PASSWORDS.includes(password.toLowerCase())) {
    issues.push('This password is too common');
  }

  return issues.length === 0 ? null : issues;
}
Enter fullscreen mode Exit fullscreen mode

#8 Data Integrity Failures

// ❌ Trusting client-side data blindly
app.post('/api/orders', (req, res) => {
  const { total, items } = req.body;
  // Client sent total = $0.01 for $1000 worth of items!
  createOrder(total, items);
});

// ✅ Server-side validation and recalculation
app.post('/api/orders', auth, async (req, res) => {
  const { items } = req.body;

  // Validate each item exists and price is current
  const validatedItems = [];
  for (const item of items) {
    const product = await Product.findById(item.productId);
    if (!product || !product.active) {
      return res.status(400).json({ error: `Invalid product: ${item.productId}` });
    }

    // Recalculate price from DB (never trust client's price!)
    validatedItems.push({
      productId: item.productId,
      quantity: Math.min(item.quantity, product.maxOrderQuantity),
      unitPrice: product.price, // From database, not request!
      subtotal: product.price * Math.min(item.quantity, product.maxOrderQuantity)
    });
  }

  const total = validatedItems.reduce((sum, i) => sum + i.subtotal, 0);
  const order = await Order.create({ userId: req.user.id, items: validatedItems, total });

  res.status(201).json(order);
});

// ❌ Missing data integrity checks (race conditions)
// Two simultaneous requests both read balance=100, both deduct 50
// Final balance: 50 instead of 0 (should be -100 which should be rejected!)

// ✅ Database transactions with proper isolation
async function transferFunds(fromId, toId, amount) {
  const result = await db.transaction(async (trx) => {
    // Acquire row-level lock (SELECT FOR UPDATE prevents concurrent reads)
    const fromAccount = await trx('accounts')
      .where('id', fromId)
      .forUpdate()          // Row lock!
      .first();

    if (fromAccount.balance < amount) {
      throw new Error('Insufficient funds');
    }

    await trx('accounts')
      .where('id', fromId)
      .decrement('balance', amount);

    await trx('accounts')
      .where('id', toId)
      .increment('balance', amount);

    return { success: true };
  });

  return result;
}
Enter fullscreen mode Exit fullscreen mode

Quick Security Checklist

Before deploying any application:

Authentication & Authorization
☐ Passwords hashed with bcrypt/scrypt/Argon2 (min 12 rounds)
☐ JWT access tokens expire in ≤15 minutes
☐ Refresh tokens stored securely, revocable
☐ Every API endpoint checks authorization
☐ Rate limiting on all auth endpoints
☐ Account lockout after failed attempts

Input Validation
☐ All inputs validated server-side (not just client-side)
☐ SQL: parameterized queries always
☐ NoSQL: operator sanitization / strict schema
☐ Shell: allowlist validation for command args
☐ File uploads: type + size limits + virus scan

Data Protection
☐ HTTPS everywhere (HSTS enabled)
☐ Sensitive data encrypted at rest
☐ PII fields identified and protected
☐ Logs don't contain sensitive data
☐ Database transactions for financial operations

Infrastructure
☐ Security headers set (Helmet/CSP)
☐ Error messages generic in production
☐ Dependencies scanned (npm audit)
☐ Debug endpoints disabled in production
☐ CORS configured correctly (not '*')

Monitoring
☐ Failed login attempt alerts
☐ Unusual traffic pattern detection
☐ Security event logging (who did what when)
☐ Incident response plan documented
Enter fullscreen mode Exit fullscreen mode

What's the most common security issue YOU see in codebases? What tools do you use for security scanning?

Follow @armorbreak for more practical developer guides.

Top comments (0)