OWASP Top 10 2025: Web Application Security Guide with Real Attack Examples
The OWASP Top 10 is the industry standard for web application security risk classification. Every developer building web apps needs to understand these. Not as abstract theory — as specific bugs that will happen in your codebase if you're not careful.
A01: Broken Access Control
The #1 risk for the third consecutive cycle. Access control failures allow attackers to act outside their intended permissions.
The attack:
GET /api/users/1234/orders ← attacker's own account
GET /api/users/5678/orders ← victim's account (same endpoint, different ID)
If your API checks only authentication (is the user logged in?) but not authorization (does this user own resource 5678?), this is Broken Access Control — also called IDOR (Insecure Direct Object Reference).
Vulnerable code:
// ❌ Only checks if logged in, not if user owns the resource
router.get('/api/orders/:orderId', authenticate, async (req, res) => {
const order = await db.order.findUnique({ where: { id: req.params.orderId } });
res.json(order);
});
Fix:
// ✅ Scope every query to the authenticated user
router.get('/api/orders/:orderId', authenticate, async (req, res) => {
const order = await db.order.findUnique({
where: {
id: req.params.orderId,
userId: req.user.id, // ← user can only access their own orders
},
});
if (!order) return res.status(404).json({ error: 'Not found' });
res.json(order);
});
For admin endpoints — explicitly check role, don't rely on "only admins know the URL":
function requireRole(role: string) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user.roles.includes(role)) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
router.delete('/api/admin/users/:id', authenticate, requireRole('admin'), deleteUser);
A02: Cryptographic Failures
Previously called "Sensitive Data Exposure" — it's about failing to protect data at rest and in transit.
Common failures:
- Storing passwords in plaintext or with weak hashing (MD5, SHA1)
- Transmitting sensitive data over HTTP instead of HTTPS
- Weak random number generation for tokens
Vulnerable code:
// ❌ MD5 is not a password hashing algorithm
const hashedPassword = crypto.createHash('md5').update(password).digest('hex');
// ❌ Predictable token generation
const resetToken = Math.random().toString(36);
Fix:
import bcrypt from 'bcrypt';
import crypto from 'crypto';
// ✅ bcrypt: slow by design (cost factor 12 = ~250ms per hash)
const SALT_ROUNDS = 12;
async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
// ✅ Cryptographically secure random token
function generateSecureToken(bytes = 32): string {
return crypto.randomBytes(bytes).toString('hex');
}
For JWT secret keys — minimum 256-bit secret, ideally asymmetric (RS256):
// ✅ RS256: private key to sign, public key to verify
import { SignJWT, jwtVerify, generateKeyPair } from 'jose';
const { privateKey, publicKey } = await generateKeyPair('RS256');
const token = await new SignJWT({ userId, role })
.setProtectedHeader({ alg: 'RS256' })
.setIssuedAt()
.setExpirationTime('15m') // Short expiry
.sign(privateKey);
A03: Injection
SQL, NoSQL, OS command, LDAP — anything where untrusted input reaches an interpreter.
SQL injection example:
// ❌ Classic SQL injection
const results = await db.query(
`SELECT * FROM users WHERE email = '${req.body.email}'`
);
// Input: admin'-- → logs in as admin without password
// Input: '; DROP TABLE users;-- → destroys your database
Fix with parameterized queries:
// ✅ Parameterized query — input is never interpreted as SQL
const results = await db.query(
'SELECT * FROM users WHERE email = $1',
[req.body.email]
);
// ✅ ORM (Prisma, Drizzle) — parameterized by default
const user = await prisma.user.findUnique({
where: { email: req.body.email },
});
NoSQL injection (MongoDB):
// ❌ Vulnerable — attacker sends: { "$gt": "" } as email
const user = await User.findOne({ email: req.body.email });
// Attacker payload: POST /login { "email": {"$gt": ""}, "password": {"$gt": ""} }
// Result: returns the first user in the database → bypasses auth
Fix:
import { z } from 'zod';
// ✅ Validate that email is actually a string before touching DB
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
const { email, password } = LoginSchema.parse(req.body);
const user = await User.findOne({ email }); // safe — email is a validated string
A04: Insecure Design
Missing security controls by design — not implementation bugs but architectural ones.
Example: Password reset via predictable token:
// ❌ Time-based predictable token
function generateResetToken(userId: string): string {
return Buffer.from(`${userId}:${Date.now()}`).toString('base64');
}
// Attacker can brute-force timestamp range for a known userId
Secure design:
// ✅ Cryptographically random token stored server-side with expiry
async function createPasswordResetToken(userId: string): Promise<string> {
const token = crypto.randomBytes(32).toString('hex');
const hash = crypto.createHash('sha256').update(token).digest('hex');
await db.passwordReset.create({
data: {
userId,
tokenHash: hash, // store hash, not the token itself
expiresAt: new Date(Date.now() + 15 * 60 * 1000), // 15 minutes
usedAt: null,
},
});
return token; // send to user, never stored plaintext
}
async function verifyResetToken(token: string): Promise<string | null> {
const hash = crypto.createHash('sha256').update(token).digest('hex');
const record = await db.passwordReset.findFirst({
where: {
tokenHash: hash,
expiresAt: { gt: new Date() },
usedAt: null,
},
});
return record?.userId ?? null;
}
A05: Security Misconfiguration
Default credentials, verbose error messages, unnecessary features enabled, missing security headers.
Security headers (critical for any web app):
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"], // tighten as needed
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'", 'https://api.yourservice.com'],
},
},
hsts: {
maxAge: 63072000, // 2 years
includeSubDomains: true,
preload: true,
},
}));
Never expose stack traces in production:
// Global error handler
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
// Log full error internally
logger.error({ err, url: req.url, userId: req.user?.id });
// Return generic error to client
if (process.env.NODE_ENV === 'production') {
res.status(500).json({ error: 'Internal server error' });
} else {
res.status(500).json({ error: err.message, stack: err.stack });
}
});
A06: Vulnerable and Outdated Components
Using packages with known CVEs.
# Audit your dependencies
npm audit
# Auto-fix non-breaking updates
npm audit fix
# Check for outdated packages
npm outdated
# Better: integrate into CI
npx audit-ci --moderate
In your CI pipeline:
# .github/workflows/security.yml
- name: Security audit
run: npm audit --audit-level=moderate
- name: Check for known vulnerabilities
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
severity: 'HIGH,CRITICAL'
A07: Identification and Authentication Failures
Weak passwords, missing brute-force protection, insecure session management.
Rate limiting on auth endpoints:
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // 10 attempts per IP per window
standardHeaders: true,
store: new RedisStore({ client: redis }), // persistent across restarts
handler: (req, res) => {
res.status(429).json({
error: 'Too many login attempts. Try again in 15 minutes.',
});
},
});
app.post('/api/auth/login', loginLimiter, loginHandler);
Never leak whether email exists:
// ❌ Leaks user enumeration
if (!user) return res.status(404).json({ error: 'User not found' });
if (!validPassword) return res.status(401).json({ error: 'Wrong password' });
// ✅ Same message regardless
if (!user || !await verifyPassword(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid email or password' });
}
A08: Software and Data Integrity Failures
Deserializing untrusted data, unsigned updates, CI/CD pipeline injection.
Never use eval() or deserialize untrusted JSON with prototypes:
// ❌ Prototype pollution via JSON merge
function merge(target: any, source: any) {
for (const key in source) {
if (typeof source[key] === 'object') {
if (!target[key]) target[key] = {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
// Source: {"__proto__": {"isAdmin": true}} → pollutes Object.prototype
// ✅ Use Object.create(null) or structured clone
const safeConfig = JSON.parse(JSON.stringify(userInput)); // strips prototype
A09: Security Logging and Monitoring Failures
If you can't detect a breach, you can't respond to it.
Minimum security events to log:
const securityLogger = {
authFailure: (email: string, ip: string, reason: string) => {
logger.warn({
event: 'AUTH_FAILURE',
email,
ip,
reason,
timestamp: new Date().toISOString(),
});
},
suspiciousActivity: (userId: string, action: string, details: object) => {
logger.warn({
event: 'SUSPICIOUS_ACTIVITY',
userId,
action,
details,
timestamp: new Date().toISOString(),
});
},
accessDenied: (userId: string, resource: string) => {
logger.warn({
event: 'ACCESS_DENIED',
userId,
resource,
timestamp: new Date().toISOString(),
});
},
};
Alert when: 5+ auth failures from one IP in 1 minute, access denied to admin resources, password changed for privileged user.
A10: Server-Side Request Forgery (SSRF)
Attacker tricks server into making HTTP requests to internal services.
// ❌ Vulnerable: fetches arbitrary URLs from user input
app.post('/api/preview', async (req, res) => {
const { url } = req.body;
const response = await fetch(url); // attacker sends http://169.254.169.254/latest/meta-data/
res.json({ content: await response.text() });
});
The 169.254.169.254 is the AWS metadata service — this leaks IAM credentials.
Fix:
import { URL } from 'url';
import dns from 'dns/promises';
import ipRangeCheck from 'ip-range-check';
const BLOCKED_RANGES = [
'10.0.0.0/8', // Private network
'172.16.0.0/12', // Private network
'192.168.0.0/16', // Private network
'127.0.0.0/8', // Loopback
'169.254.0.0/16', // Link-local (AWS metadata)
'::1/128', // IPv6 loopback
];
async function isSafeUrl(urlString: string): Promise<boolean> {
try {
const url = new URL(urlString);
if (!['http:', 'https:'].includes(url.protocol)) return false;
const addresses = await dns.resolve4(url.hostname);
return addresses.every(ip => !ipRangeCheck(ip, BLOCKED_RANGES));
} catch {
return false;
}
}
app.post('/api/preview', async (req, res) => {
const { url } = req.body;
if (!await isSafeUrl(url)) {
return res.status(400).json({ error: 'URL not allowed' });
}
// safe to fetch
});
Quick Security Checklist
[ ] All user input validated with schema (Zod/Joi)
[ ] Parameterized queries everywhere (no string concatenation in SQL)
[ ] Passwords hashed with bcrypt/argon2 (cost factor 10+)
[ ] Security headers configured (Helmet)
[ ] Rate limiting on auth endpoints
[ ] JWT with short expiry + refresh token rotation
[ ] npm audit in CI pipeline
[ ] Never expose stack traces in production
[ ] All access control checks scoped to authenticated user
[ ] HTTPS only, HSTS enabled
[ ] Structured security event logging
Aunimeda builds secure, production-grade applications. Discuss your project.
See also: Node.js vs Bun runtime comparison, TypeScript advanced types