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());
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;
}
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');
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"
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
}));
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
}
}
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 };
}
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
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);
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 });
}
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)