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 (1)

Collapse
 
asesh_mondal_ceab74b798bc profile image
Asesh Mondal

I’m working on configuring Tailwind CSS with React. I’ve followed the steps, but I’m stuck and can’t get it fully working. If anyone could assist me or walk me through the setup, it would be a huge help. I’d really appreciate any guidance

I don’t have any money, and I’m not looking for a professional mentor, but if someone could provide me some guidance, it would be really helpful for me