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 fix them in your code.

1. Broken Access Control

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

// ✅ Secure: Enforce ownership
app.get('/api/users/:id', authRequired, async (req, res) => {
  // Check user can only 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);
});

// Middleware pattern for consistent access control:
function requireOwnership(resource) {
  return async (req, res, next) => {
    const item = await resource.findById(req.params.id);
    if (!item) return res.status(404).json({ error: 'Not found' });

    const isOwner = item.userId?.toString() === req.user.id;
    const isAdmin = req.user.role === 'admin';

    if (!isOwner && !isAdmin) {
      return res.status(403).json({ error: 'Access denied' });
    }

    req.resource = item; // Pass to next handler
    next();
  };
}

// Usage:
app.put('/api/posts/:id', authRequired, requireOwnership(Post), updatePost);

// Also enforce at database level with Row Level Security (PostgreSQL):
-- CREATE POLICY user_posts ON posts FOR ALL
--   USING (user_id = current_user_id());
Enter fullscreen mode Exit fullscreen mode

2. Cryptographic Failures

// ❌ Storing passwords in plain text or weak hashing:
const hashedPassword = md5(password);           // NEVER!
const hashedPassword = sha1(password);          // NEVER!
const hashedPassword = crypto.createHash('md5').update(password).digest('hex'); // NEVER!

// ✅ Use bcrypt/argonid (purpose-built for passwords):
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12; // Higher = slower = more secure

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

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

// Usage during registration:
const passwordHash = await hashPassword(userPassword);
await db.users.create({ email, passwordHash });

// Usage during login:
const user = await db.users.findByEmail(email);
if (!user || !(await verifyPassword(inputPassword, user.password_hash))) {
  throw new AuthenticationError('Invalid credentials');
}

// For other sensitive data (API keys, tokens):
// Use AES-256-GCM (authenticated encryption):
const { createCipheriv, randomBytes, createDecipheriv } = require('crypto');

function encrypt(text, keyHex) {
  const iv = randomBytes(16); // Unique IV per encryption!
  const cipher = createCipheriv('aes-256-gcm', Buffer.from(keyHex, 'hex'), iv);

  let encrypted = cipher.update(text, 'utf8', 'hex');
  encrypted += cipher.final('hex');

  const authTag = cipher.getAuthTag(); // Integrity verification

  return {
    encrypted,
    iv: iv.toString('hex'),
    authTag: authTag.toString('hex'), // MUST store this too!
  };
}

function decrypt(encryptedData, keyHex) {
  const decipher = createDecipheriv(
    'aes-256-gcm',
    Buffer.from(keyHex, 'hex'),
    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;
}
Enter fullscreen mode Exit fullscreen mode

3. Injection (SQL, NoSQL, Command)

// === SQL Injection ===
// ❌ String concatenation (vulnerable):
const query = `SELECT * FROM users WHERE email = '${email}' AND password = '${password}'`;
// Attacker input: email = "' OR 1=1 --" → bypasses authentication!

// ✅ Parameterized queries (always!):
const user = await db.query(
  'SELECT * FROM users WHERE email = $1 AND password_hash = $2',
  [email, passwordHash]
);
// The database treats parameters as DATA, never as SQL code.

// Using ORM (most ORMs use parameterized queries by default):
const user = await User.findOne({ where: { email } }); // Safe!

// But be careful with raw queries in ORMs:
// ❌ Sequelize raw query vulnerability:
const results = await sequelize.query(`SELECT * FROM ${tableName}`); // tableName could be malicious!

// ✅ Whitelist table names:
const allowedTables = ['users', 'posts', 'comments'];
if (!allowedTables.includes(tableName)) throw new Error('Invalid table');
const results = await sequelize.query(`SELECT * FROM "${tableName}"`);

// === NoSQL Injection ===
// ❌ MongoDB injection via $where/$gt operators:
const user = await db.users.findOne({ 
  username: userInput, 
  password: passInput 
});
// Input: { "$gt": "" } → matches ANY document!

// ✅ Sanitize or use strict schema validation:
const { joi } = require('@hapi/joi');
const schema = joi.object({
  username: joi.string().alphanum().min(3).max(30).required(),
  password: joi.string().min(8).required(),
});

const { value, error } = schema.validate({ username: userInput, password: passInput });
if (error) throw new ValidationError(error.message);

const user = await db.users.findOne(value); // Validated input only

// === Command Injection ===
// ❌ Never pass user input to shell commands:
const { execSync } = require('child_process');
execSync(`convert ${filename} output.png`); // filename could be "; rm -rf /"

// ✅ Use library APIs instead of shell commands:
const sharp = require('sharp');
await sharp(filename).png().toFile('output.png'); // No shell involved

// If you MUST use exec, whitelist rigorously:
const allowedFormats = ['jpg', 'png', 'gif'];
const ext = path.extname(filename).slice(1).toLowerCase();
if (!allowedFormats.includes(ext)) throw new Error('Invalid format');
Enter fullscreen mode Exit fullscreen mode

4. Insecure Design

// ❌ Design flaw: Password reset token sent in URL (logged everywhere)
app.post('/api/reset-password', async (req, res) => {
  const token = generateToken();
  await sendEmail(req.email, `Click here: https://example.com/reset?token=${token}`);
  // Token appears in browser history, server logs, referrer headers...
});

// ✅ Secure design: Token only works via POST form
app.post('/api/reset-password', async (req, res) => {
  const token = crypto.randomBytes(32).toString('hex');
  await db.tokens.create({ token, email: req.email, expiresAt: Date.now() + 3600000 });
  await sendEmail(req.email, `Visit: https://example.com/reset-form`); // No token in URL!
  // User submits new password + token via POST (token not logged in URL)
});

// ❌ Design flaw: Sequential IDs reveal business data
// GET /api/invoices/1001 → /api/invoices/1002 → competitor knows your volume!

// ✅ Use UUIDs or non-sequential identifiers:
const invoiceId = crypto.randomUUID(); // "550e8400-e29b-41d4-a716-446655440000"
// Or use Hashids (obfuscated but reversible internally):
const hashids = new Hashids('my-salt', 10);
const publicId = hashids.encode(invoice.internalId); // "x5jY3m9kP2q"
Enter fullscreen mode Exit fullscreen mode

5. Security Misconfiguration

// ❌ Exposing error details to users:
app.use((err, req, res, next) => {
  res.status(500).json({ error: err.message, stack: err.stack }); // Leaks internals!
});

// ✅ Generic error messages in production:
app.use((err, req, res, next) => {
  const incidentId = crypto.randomUUID();
  logger.error(`[${incidentId}]`, err); // Log details server-side only
  res.status(err.statusCode || 500).json({
    error: process.env.NODE_ENV === 'production' ? 'Internal error' : err.message,
    incidentId, // Support can look it up
  });
});

// ❌ Missing security headers:
// No helmet, no CORS config, no CSP...

// ✅ Comprehensive Express security setup:
const helmet = require('helmet');
const cors = require('cors');

app.use(helmet({
  contentSecurityPolicy: {        // Prevent XSS via injected scripts
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", 'cdn.example.com'],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", 'data:', 'https:'],
    },
  },
  hsts: { maxAge: 31536000, includeSubDomains: true }, // Force HTTPS
  noSniff: true,               // Prevent MIME sniffing
  referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));

app.use(cors({
  origin: ['https://myapp.com'], // Whitelist, not wildcard!
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
}));

// Rate limiting (prevent brute force):
const rateLimit = require('express-rate-limit');

app.use('/api/auth/', rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 10,                  // 10 attempts per window
  message: { error: 'Too many requests, try again later' },
}));

app.use('/api/', rateLimit({
  windowMs: 60 * 1000,     // 1 minute
  max: 100,                // 100 requests per minute
}));
Enter fullscreen mode Exit fullscreen mode

6. Vulnerable & Outdated Components

# Audit dependencies regularly:
npm audit                    # Check for known vulnerabilities
npm audit fix               # Auto-fix where possible
npm outdated                 # Check for outdated packages

# Automated dependency updates:
# GitHub Dependabot (free for public repos)
# Renovate Bot (self-hosted)

# package.json best practices:
{
  "dependencies": {
    "express": "^4.18.2",   // Caret: allows patch/minor updates
    "lodash": "~4.17.21",   // Tilde: allows only patch updates
  },
  "devDependencies": {
    "eslint": "^8.50.0",
  },
  // Lock exact versions in production:
  "engines": {
    "node": ">=18.0.0"      // Require minimum secure version
  }
}
Enter fullscreen mode Exit fullscreen mode

7. Identification & Authentication Failures

// ❌ Weak password requirements:
if (password.length < 6) throw new Error('Password too short');

// ✅ Strong password policy + strength meter:
function validatePasswordStrength(password) {
  const checks = [
    { name: 'length', test: () => password.length >= 12 },
    { name: 'uppercase', test: () => /[A-Z]/.test(password) },
    { name: 'lowercase', test: () => /[a-z]/.test(password) },
    { name: 'digit', test: () => /\d/.test(password) },
    { name: 'special', test: () => /[!@#$%^&*(),.?":{}|<>]/.test(password) },
    { name: 'not-common', test: () => !commonPasswords.includes(password.toLowerCase()) },
  ];

  const passed = checks.filter(c => c.test());
  const score = passed.length / checks.length;

  if (score < 0.7) {
    throw new WeakPasswordError(
      `Password too weak. Missing: ${checks.filter(c => !c.test()).map(c => c.name).join(', ')}`,
      passed
    );
  }
  return score;
}

// ❌ Session management issues:
// - No session timeout
// - Session ID not regenerated after login
// - Cookies without Secure/HttpOnly flags

// ✅ Secure session configuration:
app.use(session({
  secret: process.env.SESSION_SECRET, // At least 32 random bytes!
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: true,       // Only over HTTPS
    httpOnly: true,     // Not accessible via JavaScript
    sameSite: 'strict', // CSRF protection
    maxAge: 3600000,    // 1 hour timeout
  },
  name: '__Host-session-id', // Prefix prevents cookie overwrite
}));

// Regenerate session after login (prevent session fixation):
app.post('/api/login', async (req, res) => {
  const user = await authenticate(req.body);
  req.session.regenerate((err) => { // New session ID after login
    req.session.userId = user.id;
    req.session.role = user.role;
    res.json({ success: true });
  });
});

// JWT best practices:
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 = jwt.sign(
    { userId: user.id, type: 'refresh' },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: '7d' } // Longer-lived refresh token
  );

  return { accessToken, refreshToken };
}
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>
// What if CDN is compromised? Users get malicious code!

// ✅ Subresource Integrity (SRI) hashes:
<script src="https://cdn.example.com/jquery.min.js"
        integrity="sha256-xxxhashhere"
        crossorigin="anonymous"></script>
// Browser verifies the downloaded file matches the hash before executing.

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

// ✅ Verify packages:
npm ci                   # Uses lockfile exactly (reproducible builds)
npm audit --production   # Check production deps only

// CI pipeline integrity check:
# .github/workflows/security.yml
- name: Dependency Audit
  run: |
    npm audit --audit-level=high || true
    npm outdated || true

- name: Check for secrets
  uses: trufflesecurity/trufflehog@main
  with:
    path: ./
    base: main
    head: HEAD
Enter fullscreen mode Exit fullscreen mode

9. Security Logging & Monitoring Failures

// ❌ Not logging security events (or logging too little):
console.log('Login failed'); // Lost when process restarts

// ✅ Structured security logging:
const winston = require('winston');

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [
    new winston.transports.File({ filename: 'security.log' }),
    // Also send to SIEM: Splunk, ELK, Datadog...
  ],
});

// Log EVERY security-relevant event:
function logSecurityEvent(event, details) {
  logger.warn('SECURITY_EVENT', {
    event,
    ...details,
    timestamp: new Date().toISOString(),
    ip: details.req?.ip,
    userAgent: details.req?.get('user-agent'),
    userId: details.user?.id,
  });
}

// When to log:
logSecurityEvent('LOGIN_SUCCESS', { req, user });         // Track normal patterns
logSecurityEvent('LOGIN_FAILURE', { req, reason: 'bad_password' }); // Detect brute force
logSecurityEvent('PERMISSION_DENIED', { req, resource, action }); // Detect probing
logSecurityEvent('RATE_LIMIT_EXCEEDED', { req, endpoint }); // Detect attacks
logSecurityEvent('TOKEN_INVALIDATED', { userId, reason: 'suspicious_activity' }); // Track compromises

// Alert on anomalies:
setInterval(async () => {
  const recentFailures = await countRecentEvents('LOGIN_FAILURE', { minutes: 5 });
  if (recentFailures > 20) {
    alertSecurityTeam(`Possible brute force attack! ${recentFailures} failures in 5 min`);
  }
}, 60000);
Enter fullscreen mode Exit fullscreen mode

10. Server-Side Request Forgery (SSRF)

// ❌ Allowing user-controlled URLs in backend requests:
app.get('/api/fetch-url', async (req, res) => {
  const data = await fetch(req.query.url); // User controls URL!
  res.json(await data.json()); // Can access internal services!
});
// Attacker: ?url=http://169.254.169.254/latest/meta-data/ (AWS metadata!)
// Attacker: ?url=http://localhost:6379/ (Redis!)

// ✅ SSRF protection:
const { URL } = require('url');
const net = require('net');
const DNS_REBIND_PROTECTION_TTL = 30;

async function safeFetch(urlString, options = {}) {
  let url;
  try {
    url = new URL(urlString);
  } catch {
    throw new Error('Invalid URL');
  }

  // Block private/internal IP ranges
  const blockedPatterns = [
    /^127\./,            // Loopback
    /^10\./,             // Private class A
    /^172\.(1[6-9]|2\d|3[01])\./, // Private class B
    /^192\.168\./,       // Private class C
    /^169\.254\./,       // Link-local
    /^::1$/,             // IPv6 loopback
    /^fc00:/i,           // IPv6 unique local
    /^fe80:/i,           // IPv6 link-local
  ];

  // Resolve hostname FIRST (prevent DNS rebinding)
  const hostname = url.hostname;
  const addresses = await dns.promises.resolve(hostname);

  for (const addr of addresses) {
    if (blockedPatterns.some(p => p.test(addr))) {
      throw new Error('Blocked: internal address resolution');
    }
  }

  // Protocol whitelist
  const allowedProtocols = ['http:', 'https:'];
  if (!allowedProtocols.includes(url.protocol)) {
    throw new Error('Protocol not allowed');
  }

  // Port whitelist (block common internal service ports)
  const blockedPorts = [22, 25, 3306, 5432, 6379, 9200, 27017];
  if (blockedPorts.includes(parseInt(url.port))) {
    throw new Error('Port not allowed');
  }

  return fetch(url, { ...options, timeout: 10000 });
}
Enter fullscreen mode Exit fullscreen mode

Which OWASP vulnerability have you encountered most? What's your security tip that wasn't covered?

Follow @armorbreak for more practical developer guides.

Top comments (0)