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
Aunimeda builds secure software systems with security-first architecture - authentication, authorization, and security audits.
Contact us to discuss your project. See also: Custom Software Development