OWASP Top 10
Protect your application against the most critical web security risks.
What is OWASP?#
The Open Web Application Security Project (OWASP) is a nonprofit foundation that works to improve software security. Their most famous contribution is the OWASP Top 10 - a regularly updated list of the most critical security risks to web applications.
Think of it as a "most wanted" list for web vulnerabilities. If you protect against these 10 categories, you've addressed the vast majority of real-world attacks.
Why this matters: Most successful attacks exploit these common vulnerabilities. Understanding and preventing them is essential for any production application.
OWASP Top 10 (2021)#
| Rank | Vulnerability | What Goes Wrong |
|---|---|---|
| A01 | Broken Access Control | Users access data or actions they shouldn't |
| A02 | Cryptographic Failures | Sensitive data exposed through weak encryption |
| A03 | Injection | Attacker's code executed by your system |
| A04 | Insecure Design | Security wasn't considered in architecture |
| A05 | Security Misconfiguration | Default settings, verbose errors, open ports |
| A06 | Vulnerable Components | Outdated libraries with known exploits |
| A07 | Authentication Failures | Weak passwords, session hijacking, credential stuffing |
| A08 | Data Integrity Failures | Untrusted data modifies behavior |
| A09 | Logging Failures | Attacks go undetected |
| A10 | SSRF | Server tricked into making internal requests |
A01: Broken Access Control#
What it is: Users performing actions or accessing data outside their intended permissions. This is the #1 vulnerability because it's easy to miss and has severe consequences.
Real-world impact:
- A user changing
?userId=123to?userId=124in the URL and accessing another user's account - Regular users accessing admin-only endpoints
- Viewing, editing, or deleting other users' data
Why it happens: Developers assume the frontend will only show allowed options, forgetting that anyone can craft HTTP requests directly.
The Vulnerable Code#
// VULNERABLE - No authorization check
app.get('/api/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.json(user); // Any user can access any profile!
});
The problem: We check who the user is (authentication) but not what they're allowed to do (authorization).
The Secure Approach#
// SECURE - Check ownership
app.get('/api/users/:id', authenticate, async (req, res) => {
// Users can only access their own profile
if (req.params.id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
const user = await User.findById(req.params.id);
res.json(user);
});
// SECURE - Filter by ownership in query
app.get('/api/orders', authenticate, async (req, res) => {
// Only return the current user's orders - can't request others
const orders = await Order.find({ userId: req.user.id });
res.json(orders);
});
// SECURE - RBAC middleware for admin operations
function requireRole(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
app.delete('/api/users/:id', authenticate, requireRole('admin'), deleteUser);
Key principle: Never trust the client. Always verify permissions on the server for every request.
A02: Cryptographic Failures#
What it is: Sensitive data exposed because of weak or missing encryption. This includes data in transit (network) and at rest (storage).
Real-world impact:
- Password databases leaked with plain-text or weakly hashed passwords
- Credit card numbers transmitted over HTTP (not HTTPS)
- API keys stored in plain text in databases
Common mistakes:
- Storing passwords in plain text or with MD5/SHA1 (easily cracked)
- Not using HTTPS
- Hardcoding secrets in source code
- Using weak encryption algorithms
Secure Password Handling#
Passwords should never be stored directly. Use a slow, salted hashing algorithm designed for passwords:
// SECURE - Hash passwords with bcrypt
import bcrypt from 'bcrypt';
const SALT_ROUNDS = 12; // Higher = slower = more secure
async function hashPassword(password) {
return bcrypt.hash(password, SALT_ROUNDS);
}
async function verifyPassword(password, hash) {
return bcrypt.compare(password, hash);
}
Why bcrypt? It's intentionally slow (configurable via salt rounds), making brute-force attacks impractical. MD5 can hash billions of passwords per second; bcrypt handles thousands at most.
Encrypting Sensitive Data#
For data you need to retrieve (unlike passwords), use proper encryption:
// SECURE - Encrypt sensitive data
import crypto from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const KEY = Buffer.from(process.env.ENCRYPTION_KEY, 'hex'); // 32 bytes
function encrypt(text) {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
// Store IV and auth tag with the encrypted data
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
}
function decrypt(encryptedData) {
const [ivHex, authTagHex, encrypted] = encryptedData.split(':');
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, KEY, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
Why AES-256-GCM? It provides both encryption (confidentiality) and authentication (integrity). The auth tag ensures the data hasn't been tampered with.
A03: Injection#
What it is: Attacker-supplied data is interpreted as code or commands by your system. The attacker "injects" malicious instructions into your queries.
Real-world impact:
- Entire databases stolen
- Data deleted or modified
- Server compromised via command execution
- Authentication bypassed
SQL Injection#
The classic attack. User input becomes part of a SQL query:
// VULNERABLE
const query = `SELECT * FROM users WHERE email = '${email}'`;
// If email = "' OR '1'='1" the query becomes:
// SELECT * FROM users WHERE email = '' OR '1'='1'
// This returns ALL users!
The fix: Never concatenate user input into queries. Use parameterized queries:
// SECURE - Parameterized queries
const result = await db.query(
'SELECT * FROM users WHERE email = $1',
[email] // Database treats this as data, not code
);
// SECURE - Using ORM (Prisma)
const user = await prisma.user.findUnique({
where: { email }, // Automatically parameterized
});
NoSQL Injection#
MongoDB and other NoSQL databases have their own injection risks:
// VULNERABLE
const user = await User.findOne({ email: req.body.email });
// If email = { "$gt": "" } the query matches all documents!
// SECURE - Validate input types
import { z } from 'zod';
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
app.post('/login', async (req, res) => {
const { email, password } = loginSchema.parse(req.body);
// Zod ensures email is a string, not an object
const user = await User.findOne({ email });
});
// SECURE - Sanitize MongoDB queries
import mongoSanitize from 'express-mongo-sanitize';
app.use(mongoSanitize()); // Strips $ and . from input
Command Injection#
When user input reaches system commands:
// VULNERABLE
const output = execSync(`convert ${req.body.filename} output.png`);
// If filename = "; rm -rf /" the server is destroyed
// SECURE - Use arrays, not strings
import { execFile } from 'child_process';
execFile('convert', [filename, 'output.png'], (error, stdout) => {
// Arguments are escaped automatically
});
// SECURE - Validate and sanitize filenames
const safeFilename = path.basename(filename); // Removes path traversal
if (!/^[\w.-]+$/.test(safeFilename)) {
throw new Error('Invalid filename');
}
A04: Insecure Design#
What it is: Security flaws in the fundamental design of your application, not bugs in implementation. You can't patch your way out of a design flaw.
Real-world impact:
- Unlimited login attempts enabling brute-force attacks
- Password reset links that never expire
- No limits on expensive operations (report generation, file uploads)
The difference: A SQL injection is an implementation bug (you wrote vulnerable code). Not having rate limiting on login is a design flaw (you never planned for the threat).
Designing Security In#
// Rate limiting for sensitive operations
import rateLimit from 'express-rate-limit';
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per window
message: { error: 'Too many login attempts. Try again in 15 minutes.' },
});
app.post('/login', loginLimiter, loginHandler);
Account Lockout#
Prevent brute-force attacks by locking accounts after failed attempts:
async function login(email, password) {
const user = await User.findOne({ email });
// Check if account is locked
if (user.lockoutUntil && user.lockoutUntil > Date.now()) {
const minutesLeft = Math.ceil((user.lockoutUntil - Date.now()) / 60000);
throw new Error(`Account locked. Try again in ${minutesLeft} minutes.`);
}
const valid = await verifyPassword(password, user.password);
if (!valid) {
// Track failed attempts
user.failedAttempts = (user.failedAttempts || 0) + 1;
if (user.failedAttempts >= 5) {
user.lockoutUntil = Date.now() + 30 * 60 * 1000; // Lock for 30 minutes
}
await user.save();
throw new Error('Invalid credentials');
}
// Reset on successful login
user.failedAttempts = 0;
user.lockoutUntil = null;
await user.save();
return user;
}
Design questions to ask:
- What if a user tries this a million times?
- What if an attacker automates this?
- What's the worst case if this is abused?
A05: Security Misconfiguration#
What it is: Insecure default settings, incomplete configuration, verbose error messages, or unnecessary features enabled.
Real-world impact:
- Stack traces revealing internal paths and technology stack
- Default admin credentials left unchanged
- Unnecessary ports open to the internet
- Debug features enabled in production
Secure Express Configuration#
// SECURE - Production configuration
import helmet from 'helmet';
app.use(helmet()); // Sets secure HTTP headers automatically
// Disable detailed errors in production
app.use((err, req, res, next) => {
console.error(err); // Log for debugging
res.status(err.status || 500).json({
error: process.env.NODE_ENV === 'production'
? 'Internal server error' // Generic message for users
: err.message, // Detailed for development
});
});
// Hide what technology you're using
app.disable('x-powered-by');
// Strict CORS configuration
import cors from 'cors';
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || false, // Whitelist domains
credentials: true,
}));
What Helmet does: Sets headers like X-Content-Type-Options, X-Frame-Options, and Strict-Transport-Security that prevent common attacks.
A06: Vulnerable Components#
What it is: Using libraries or frameworks with known security vulnerabilities. Attackers actively scan for these because exploits are publicly documented.
Real-world impact:
- The 2017 Equifax breach exploited a known Apache Struts vulnerability that had a patch available
- Log4Shell (2021) affected millions of systems running vulnerable Log4j versions
Staying Secure#
# Check for known vulnerabilities
npm audit
# Auto-fix what's possible
npm audit fix
# Use third-party security scanning
npx snyk test
Automate Dependency Updates#
// package.json
{
"scripts": {
"check-deps": "npm-check-updates",
"update-deps": "npm-check-updates -u && npm install"
}
}
Best practices:
- Run
npm auditin CI/CD pipelines - Enable Dependabot or similar automated updates
- Subscribe to security advisories for your major dependencies
- Remove unused dependencies
A07: Authentication Failures#
What it is: Flaws in how your application verifies user identity. This includes weak passwords, credential stuffing, session hijacking, and missing multi-factor authentication.
Real-world impact:
- Account takeovers
- Unauthorized access to sensitive data
- Credential stuffing attacks using leaked password lists
Secure Session Configuration#
// SECURE - Strong session configuration
import session from 'express-session';
app.use(session({
secret: process.env.SESSION_SECRET,
name: 'sessionId', // Don't use default 'connect.sid' (reveals Express)
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // JavaScript can't access the cookie
secure: process.env.NODE_ENV === 'production', // HTTPS only
sameSite: 'strict', // Prevents CSRF
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
}));
JWT with Proper Expiration#
Short-lived access tokens limit the damage if a token is stolen:
// SECURE - JWT with proper expiration
import jwt from 'jsonwebtoken';
function generateTokens(user) {
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' } // Short-lived - limits exposure
);
const refreshToken = jwt.sign(
{ userId: user.id },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' } // Longer-lived, stored securely
);
return { accessToken, refreshToken };
}
Why two tokens? Access tokens are used frequently and short-lived. If stolen, they expire quickly. Refresh tokens are used less often and can be rotated/revoked.
A08: Data Integrity Failures#
What it is: Code and infrastructure that doesn't protect against integrity violations. This includes insecure deserialization, unverified updates, and unsigned data.
Real-world impact:
- Tampered configuration files
- Man-in-the-middle attacks modifying data in transit
- Forged webhooks triggering false payments
Verify Webhook Signatures#
When receiving webhooks, verify they came from the expected source:
// SECURE - Verify webhook signatures
function verifyWebhookSignature(payload, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
// Timing-safe comparison prevents timing attacks
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
app.post('/webhooks/payment', (req, res) => {
const signature = req.headers['x-webhook-signature'];
const isValid = verifyWebhookSignature(
JSON.stringify(req.body),
signature,
process.env.WEBHOOK_SECRET
);
if (!isValid) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Process the verified webhook...
});
Sign Sensitive Data#
When you send data that will be returned (like email verification tokens), sign it:
// SECURE - Sign sensitive data
function signData(data) {
const payload = JSON.stringify(data);
const signature = crypto
.createHmac('sha256', process.env.SIGNING_SECRET)
.update(payload)
.digest('hex');
return { payload, signature };
}
function verifySignedData(payload, signature) {
const expected = crypto
.createHmac('sha256', process.env.SIGNING_SECRET)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
A09: Security Logging & Monitoring#
What it is: Failure to log security-relevant events, detect attacks, or respond to breaches. Without logging, you won't know you've been attacked until it's too late.
Real-world impact:
- Attackers operate undetected for months
- No forensic evidence after a breach
- Compliance failures (PCI-DSS, HIPAA, GDPR require logging)
What to Log#
import pino from 'pino';
const logger = pino({
level: 'info',
redact: ['password', 'token', 'authorization'], // Never log secrets
});
// Log authentication events
async function login(email, password, req) {
const user = await authenticate(email, password);
logger.info({
event: 'login_success',
userId: user.id,
ip: req.ip,
userAgent: req.headers['user-agent'],
timestamp: new Date().toISOString(),
});
return user;
}
// Log failed attempts (potential attack)
logger.warn({
event: 'login_failed',
email,
ip: req.ip,
reason: 'invalid_password',
});
// Log access control violations (definite attack)
logger.warn({
event: 'access_denied',
userId: req.user.id,
resource: req.path,
method: req.method,
});
Security events to log:
- All authentication attempts (success and failure)
- Authorization failures
- Input validation failures
- Application errors and exceptions
- Admin and privileged operations
What to monitor:
- Unusual login patterns (many failures, unusual locations)
- Sudden spikes in errors
- Suspicious user agent strings
- Access to sensitive endpoints
A10: Server-Side Request Forgery (SSRF)#
What it is: Tricking your server into making HTTP requests to unintended destinations, typically internal services or cloud metadata endpoints.
Real-world impact:
- Accessing AWS/GCP metadata to steal credentials
- Port scanning internal networks
- Reading internal service data
- Bypassing firewalls
The Vulnerability#
// VULNERABLE - Server fetches arbitrary URLs
app.get('/fetch', async (req, res) => {
const response = await fetch(req.query.url);
res.send(await response.text());
});
// Attacker requests:
// /fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/
// This returns AWS credentials!
The Secure Approach#
import { URL } from 'url';
const ALLOWED_HOSTS = ['api.example.com', 'cdn.example.com'];
function validateUrl(urlString) {
const url = new URL(urlString);
// Block internal IP ranges
const blockedPatterns = [
/^127\./, // Localhost
/^10\./, // Private Class A
/^172\.(1[6-9]|2[0-9]|3[0-1])\./, // Private Class B
/^192\.168\./, // Private Class C
/^169\.254\./, // Link-local (AWS metadata)
/^localhost$/i,
];
if (blockedPatterns.some(p => p.test(url.hostname))) {
throw new Error('Internal URLs not allowed');
}
// Whitelist approach - only allow known-good hosts
if (!ALLOWED_HOSTS.includes(url.hostname)) {
throw new Error('Host not allowed');
}
return url.toString();
}
app.get('/fetch', async (req, res) => {
try {
const safeUrl = validateUrl(req.query.url);
const response = await fetch(safeUrl);
res.send(await response.text());
} catch (error) {
res.status(400).json({ error: error.message });
}
});
Best practices:
- Use an allowlist of permitted hosts rather than trying to block all bad ones
- Disable redirects or validate redirect targets
- Use a separate network segment for services that fetch external URLs
Key Takeaways#
-
Validate all input - Never trust user data. Validate type, length, format, and range.
-
Use parameterized queries - Never concatenate user input into SQL/NoSQL queries.
-
Check authorization everywhere - Every endpoint, every request. The frontend is not a security boundary.
-
Keep dependencies updated - Run
npm auditregularly. Subscribe to security advisories. -
Log security events - You can't respond to attacks you don't detect.
-
Design for abuse - Ask "what if an attacker tries this a million times?" for every feature.
Security is not a feature, it's a process. These vulnerabilities keep appearing because developers make the same mistakes. Understanding why these patterns are dangerous is more valuable than memorizing solutions.
Ready to level up your skills?
Explore more guides and tutorials to deepen your understanding and become a better developer.