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.
#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);
#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();
});
}
#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
// 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 => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
})[char]);
#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);
});
#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)!
#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
#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 });
}
#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
#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
#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());
});
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)