DEV Community

Alex Chen
Alex Chen

Posted on

Web Security: OWASP Top 10 for Developers (2026)

Web Security: OWASP Top 10 for Developers (2026)

Security isn't a feature you add later — it's built into how you code. Here's what every developer needs to know about the OWASP Top 10.

What is OWASP Top 10?

OWASP = Open Web Application Security Project
Top 10 = The 10 most critical web application security risks

This is the industry standard. If you're building web apps,
you need to know these. Not knowing is not an excuse.
Enter fullscreen mode Exit fullscreen mode

#1 Broken Access Control

// ❌ Vulnerable: Users can access other users' data
app.get('/api/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id);
  // Anyone can request any ID! No authorization check!
  res.json(user);
});

// ✅ Secure: Check ownership
app.get('/api/users/:id', authMiddleware, async (req, res) => {
  // Only allow users to access their OWN data
  if (req.params.id !== req.user.id && req.user.role !== 'admin') {
    return res.status(403).json({ error: 'Forbidden' });
  }
  const user = await db.users.findById(req.params.id);
  res.json(user);
});

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

    // Resource belongs to current user OR user is admin
    if (resource.userId !== req.user.id && req.user.role !== 'admin') {
      return res.status(403).json({ error: 'Access denied' });
    }
    req.resource = resource;
    next();
  };
}

// Usage:
app.get('/api/posts/:id', auth, requireOwnership('posts'), getPost);
Enter fullscreen mode Exit fullscreen mode

#2 Cryptographic Failures (was Sensitive Data Exposure)

// ❌ Storing passwords in plain text or weak hash
const password = 'user_password';
db.users.insert({ email, password }); // PLAIN TEXT! Criminal negligence

// ❌ Using MD5 or SHA1 (fast to crack with rainbow tables)
const hashed = crypto.createHash('md5').update(password).digest('hex');

// ✅ Secure: bcrypt with proper work factor
const bcrypt = require('bcrypt');
const saltRounds = 12; // Higher = slower = more secure (adjust per hardware)
const hashedPassword = await bcrypt.hash(password, saltRounds);

// Verification:
const match = await bcrypt.compare(inputPassword, storedHash);

// ✅ For passwords, ALWAYS use:
// - bcrypt, scrypt, or Argon2id (NOT SHA/MD5)
// - Salt (unique per password)
// - High work factor (bcrypt cost ≥ 12)
// - NEVER roll your own crypto!

// ✅ Sensitive data in transit — HTTPS everywhere
// In Express:
const helmet = require('helmet');
app.use(helmet()); // Sets security headers including HSTS

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

#3 Injection (SQL, NoSQL, Command, XSS)

-- SQL Injection: The classic vulnerability
-- ❌ Vulnerable (string concatenation):
SELECT * FROM users WHERE email = '$email' AND password = '$password'
-- Attacker inputs: email = "' OR 1=1 --"
-- Result: SELECT * FROM users WHERE email = '' OR 1=1 --' AND password = ''
-- Returns ALL users! Bypasses authentication entirely!

-- ✅ Secure (parameterized queries / prepared statements):
SELECT * FROM users WHERE email = ? AND password = ?
-- Parameters are sent separately, never interpreted as SQL code
Enter fullscreen mode Exit fullscreen mode
// JavaScript implementation:

// ❌ SQL Injection vulnerable:
const query = `SELECT * FROM products WHERE category = '${req.query.category}'`;
db.query(query); // Attacker sets category = '; DROP TABLE products; --

// ✅ Parameterized query (prevents SQL injection):
const query = 'SELECT * FROM products WHERE category = ?';
db.query(query, [req.query.category]); // Safe!

// Or using query builder (Knex.js):
knex('products').where('category', req.query.category).select();

// ❌ NoSQL Injection (MongoDB):
const query = { username: req.body.username, password: req.body.password };
// Attacker sends: username: { "$gt": "" }, password: { "$gt": "" }
// Matches ANY document! Bypasses auth entirely.

// ✅ NoSQL safe (type checking + schema validation):
const { object } = require('yup');
const loginSchema = object({
  username: string().required().max(100),
  password: string().required().max(100),
});
await loginSchema.validate(req.body); // Rejects non-string values!
db.users.findOne({ username: req.body.username, password: req.body.password });

// ❌ Command Injection:
const { exec } = require('child_process');
exec(`convert ${req.body.filename} output.png`);
// Attacker: filename = "; rm -rf /; echo "

// ✅ Never pass user input to shell commands!
// Use libraries instead of shell commands:
const sharp = require('sharp');
await sharp(req.file.path).png().toFile('output.png');

// ❌ Cross-Site Scripting (XSS):
res.send(`<h1>Hello, ${req.query.name}</h1>`);
// Attacker: name = "<script>stealCookies()</script>"

// ✅ Output encoding / templating engines:
res.render('greeting', { name: req.query.name }); // Auto-escaped by template engine
// Or manual escaping:
const escaped = req.query.name.replace(/[&<>"']/g, char => ({
  '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
})[char]);
Enter fullscreen mode Exit fullscreen mode

#4 Insecure Design

// ❌ Insecure by design: Password reset uses predictable tokens
function generateResetToken() {
  return Math.random().toString(36).substring(7); // Predictable! Guessable!
}
// Token: "x7f9a2b" → attacker can brute force this easily

// ✅ Secure design: Cryptographically secure random tokens
const crypto = require('crypto');
function generateResetToken() {
  return crypto.randomBytes(32).toString('hex'); // 64 hex chars = 256 bits
}
// Token: "a7f9c2e8..." → 2^256 possible values → impossible to brute force

// ❌ Insecure design: Rate limiting on client side
// <script>
//   let attempts = 0;
//   function tryLogin() { if (attempts < 5) { attempts++; submitLogin(); } }
// </script>
// Client can just remove this check!

// ✅ Secure design: Server-side rate limiting
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5,                    // 5 attempts per window
  message: { error: 'Too many attempts. Try again later.' },
  standardHeaders: true,
});
app.post('/api/login', loginLimiter, handleLogin);

// ❌ Insecure design: Trusting client input for business logic
app.post('/api/purchase', (req, res) => {
  const { itemId, price } = req.body;
  // Client sends the price! They can set price = $0.01 for anything!
  processPayment(price);
});

// ✅ Secure design: Server validates everything
app.post('/api/purchase', auth, async (req, res) => {
  const item = await db.items.findById(req.body.itemId);
  if (!item) return res.status(404).json({ error: 'Item not found' });

  const price = item.price; // Use SERVER price, not client price!
  // Apply server-side discounts only
  if (req.user.hasDiscount) price *= 0.9;

  await processPayment(price, req.user.id);
});
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 });
  // Leaks file paths, library versions, internal architecture!
});

// ✅ Production-safe error handler
app.use((err, req, res, next) => {
  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: err.incidentId, // For support lookup
  });
});

// ❌ Default credentials, unnecessary features enabled
// Running with DEBUG=true in production
// Leaving admin panel at /admin with default password
// CORS allowing all origins (*)

// ✅ Security headers via Helmet:
const helmet = require('helmet');
app.use(helmet({
  contentSecurityPolicy: {           // Prevents XSS/data injection
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "cdn.example.com"],
      styleSrc: ["'self'", "'unsafe-inline'"], // If needed for CSS frameworks
      imgSrc: ["'self'", "data:", "https:"],
    },
  },
  hsts: { maxAge: 31536000, includeSubDomains: true }, // Force HTTPS
  noSniff: true,                                       // Prevent MIME sniffing
  referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));

// ✅ CORS configuration (only allow your frontend):
const cors = require('cors');
app.use(cors({
  origin: ['https://yourdomain.com', 'https://www.yourdomain.com'],
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
}));
// Never use cors() without options (allows ALL origins)!
Enter fullscreen mode Exit fullscreen mode

#6 Vulnerable & Outdated Components

# Check for vulnerabilities DAILY:
npm audit              # Show known vulnerabilities
npm audit fix          # Auto-fix where possible (safe fixes only)
npm audit fix --force  # Fix breaking changes too (review carefully!)

# Integrate into CI:
# .github/workflows/security.yml
name: Security Audit
on: [push, pull_request]
jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: npm install
      - run: npm audit --audit-level=moderate  # Fail on moderate+ severity

# Automated dependency updates:
# Use Dependabot (GitHub native) or Renovate
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: npm
    directory: /
    schedule:
      interval: weekly
    open-pull-requests-limit: 10
    # Auto-merge security patches:
    groups:
      production-dependencies:
        patterns:
          "*"
        update-types:
          - minor
          - patch
Enter fullscreen mode Exit fullscreen mode

#7 Identification & Authentication Failures

// ❌ Weak password policy allows "123456"
// ✅ Strong password requirements:
const passwordSchema = yup.object({
  password: string()
    .min(12, 'Must be at least 12 characters')
    .matches(/[a-z]/, 'Must contain lowercase letter')
    .matches(/[A-Z]/, 'Must contain uppercase letter')
    .matches(/\d/, 'Must contain digit')
    .matches(/[^a-zA-Z0-9]/, 'Must contain special character'),
});

// ❌ No account lockout (infinite brute force attempts)
// ✅ Account lockout after failed attempts:
const MAX_ATTEMPTS = 5;
const LOCKOUT_TIME = 30 * 60 * 1000; // 30 minutes

async function handleFailedLogin(userId) {
  await db.loginAttempts.increment(userId);
  const attempts = await db.loginAttempts.count(userId, LOCKOUT_TIME);

  if (attempts >= MAX_ATTEMPTS) {
    await db.users.lockAccount(userId, new Date(Date.now() + LOCKOUT_TIME));
    sendLockoutEmail(userId);
  }
}

// ❌ Session tokens that never expire
// ✅ Short-lived JWTs + refresh token rotation:
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! 15 minutes max
  );

  const refreshToken = crypto.randomBytes(64).toString('hex'); // Opaque token

  return { accessToken, refreshToken };
}

// Store refresh tokens server-side, rotate on each use:
// When user presents old refresh token → issue NEW one, invalidate old
// Detect if same refresh token used twice → possible theft, revoke all!

// ✅ Multi-Factor Authentication (MFA):
// Implement TOTP (Time-based One-Time Password) using otplib:
const { authenticator } = require('otplib');
authenticator.options = { step: 30 }; // 30-second codes

function verifyTOTP(secret, token) {
  return authenticator.verify({ secret, token });
}
Enter fullscreen mode Exit fullscreen mode

#8 Software & Data Integrity Failures

// ❌ Loading scripts from CDN without integrity check
<script src="https://cdn.example.com/jquery.min.js"></script>
// If CDN is compromised, users load malicious code!

// ✅ Subresource Integrity (SRI) hashes:
<script src="https://cdn.example.com/jquery.min.js"
        integrity="sha384-abc123...hash..."
        crossorigin="anonymous"></script>
// Browser verifies hash before executing. Mismatch = blocked!

// ❌ Installing packages without verifying:
npm install suspicious-package  # Could contain anything!

// ✅ Verify before installing:
npm view suspicious-package         # Check publisher, downloads, last publish
npm audit                         # Check for vulnerabilities
# Use npm ci (uses lockfile exactly) instead of npm install in CI
# Pin dependencies with package-lock.json or yarn.lock

// ✅ CI/CD pipeline protection:
# Require signed commits in GitHub:
# .github/branch-protection.yml requires:
# - Signed commits
# - Status checks passing
# - Code review approval

# Use tools like Sigstore/Cosign for container image signing
Enter fullscreen mode Exit fullscreen mode

#9 Security Logging & Monitoring Failures

// ❌ No logging of security events
// ✅ Comprehensive security logging:
const securityLogger = {
  loginSuccess(req, user) {
    logger.info('LOGIN_SUCCESS', {
      userId: user.id,
      ip: req.ip,
      userAgent: req.get('user-agent'),
      timestamp: new Date(),
    });
  },

  loginFailure(req, reason) {
    logger.warn('LOGIN_FAILURE', {
      ip: req.ip,
      userAgent: req.get('user-agent'),
      reason, // 'wrong_password', 'account_locked', etc.
      timestamp: new Date(),
    });
  },

  permissionDenied(req, resource, action) {
    logger.warn('PERMISSION_DENIED', {
      userId: req.user?.id,
      ip: req.ip,
      resource,
      action,
      timestamp: new Date(),
    });
  },

  suspiciousActivity(req, type, details) {
    logger.error('SUSPICIOUS_ACTIVITY', {
      type, // 'rate_limit_exceeded', 'token_reuse', 'brute_force_detected'
      details,
      ip: req.ip,
      timestamp: new Date(),
    });
    // Also trigger alerting:
    alertTeam(type, details, req.ip);
  },
};

// ✅ Set up alerting thresholds:
// - More than 5 failed logins from same IP in 15 min → Alert
// - Access from unusual country → Alert
// - Admin actions outside business hours → Alert
// - Multiple accounts from same device → Alert
Enter fullscreen mode Exit fullscreen mode

#10 Server-Side Request Forgery (SSRF)

// ❌ User can specify any URL for server to fetch
app.post('/api/fetch-url', async (req, res) => {
  const data = await fetch(req.body.url); // User controls URL!
  res.json(await data.json());
});
// Attacker: url = http://169.254.169.254/latest/meta-data/ (AWS metadata!)
// Or: url = http://internal-admin-panel:3000/admin/delete-all

// ✅ SSRF protection: Allowlist approach
const ALLOWED_DOMAINS = [
  'api.public-service.com',
  'cdn.trusted-source.net',
];

function isAllowedUrl(url) {
  try {
    const parsed = new URL(url);
    // Block private/internal IP ranges
    if (isPrivateIP(parsed.hostname)) return false;
    // Only allow specific domains
    return ALLOWED_DOMAINS.some(d => parsed.hostname === d || 
                                   parsed.hostname.endsWith('.' + d));
  } catch {
    return false; // Invalid URL
  }
}

function isPrivateIP(hostname) {
  // Resolve hostname to IP first (DNS rebinding protection!)
  // Then check against private ranges:
  // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8
  // ::1, fc00::/7, fe80::/10, 169.254.0.0/16
}

app.post('/api/fetch-url', async (req, res) => {
  if (!isAllowedUrl(req.body.url)) {
    return res.status(403).json({ error: 'URL not allowed' });
  }
  const data = await fetch(req.body.url, { timeout: 5000, maxRedirects: 3 });
  res.json(await data.json());
});
Enter fullscreen mode Exit fullscreen mode

Which OWASP Top 10 vulnerability have you encountered most? How do you handle security in your projects?

Follow @armorbreak for more practical developer guides.

Top comments (0)