Security — OWASP Top 10
The OWASP Top 10 is the most widely recognized list of web application security risks. Published by the Open Web Application Security Project, it represents the consensus of security experts on the most critical vulnerabilities found in web applications.
Understanding these risks is the foundation of application security. Each category includes real-world attack patterns, example code, and mitigation strategies. Use this guide as a reference when designing, building, and reviewing applications.
Broken Access Control occurs when users can access resources or perform actions beyond their permissions. This is the most common and most severe OWASP category.
| 1 | // Insecure Direct Object Reference (IDOR) |
| 2 | // BAD — user can access any invoice by changing the ID |
| 3 | app.get('/api/invoices/:id', async (req, res) => { |
| 4 | const invoice = await db.findInvoice(req.params.id); |
| 5 | res.json(invoice); // No ownership check! |
| 6 | }); |
| 7 | |
| 8 | // GOOD — verify ownership before returning data |
| 9 | app.get('/api/invoices/:id', async (req, res) => { |
| 10 | const invoice = await db.findInvoice(req.params.id); |
| 11 | |
| 12 | if (!invoice) { |
| 13 | return res.status(404).json({ error: 'Not found' }); |
| 14 | } |
| 15 | |
| 16 | if (invoice.userId !== req.session.userId && req.session.role !== 'admin') { |
| 17 | return res.status(403).json({ error: 'Forbidden' }); |
| 18 | } |
| 19 | |
| 20 | res.json(invoice); |
| 21 | }); |
| 22 | |
| 23 | // Missing function-level access control |
| 24 | function requireRole(role: string) { |
| 25 | return (req, res, next) => { |
| 26 | if (req.session.role !== role) { |
| 27 | return res.status(403).json({ error: 'Admin access required' }); |
| 28 | } |
| 29 | next(); |
| 30 | }; |
| 31 | } |
| 32 | |
| 33 | app.delete('/api/admin/users/:id', requireRole('admin'), async (req, res) => { |
| 34 | await db.deleteUser(req.params.id); |
| 35 | res.json({ success: true }); |
| 36 | }); |
best practice
Cryptographic failures (previously "Sensitive Data Exposure") cover weak encryption, missing encryption, and improper certificate validation. This includes transmitting sensitive data in clear text, using weak ciphers, or mishandling keys.
| 1 | // Password hashing — NEVER use MD5, SHA1, or unsalted hashes |
| 2 | import bcrypt from 'bcrypt'; |
| 3 | |
| 4 | // BAD — unsalted SHA256 (trivial to crack) |
| 5 | const hash = crypto.createHash('sha256').update(password).digest('hex'); |
| 6 | |
| 7 | // GOOD — bcrypt with cost factor 12 |
| 8 | const saltRounds = 12; |
| 9 | const hash = await bcrypt.hash(password, saltRounds); |
| 10 | |
| 11 | const isValid = await bcrypt.compare(password, storedHash); |
| 12 | |
| 13 | // GOOD — argon2 (preferred for new applications) |
| 14 | import argon2 from 'argon2'; |
| 15 | |
| 16 | const hash = await argon2.hash(password, { |
| 17 | type: argon2.argon2id, |
| 18 | memoryCost: 65536, |
| 19 | timeCost: 3, |
| 20 | parallelism: 4, |
| 21 | }); |
| 22 | |
| 23 | const isValid = await argon2.verify(storedHash, password); |
| 24 | |
| 25 | // Encryption — use authenticated encryption |
| 26 | // BAD — ECB mode, no authentication |
| 27 | const cipher = crypto.createCipheriv('aes-128-ecb', key, null); |
| 28 | |
| 29 | // GOOD — AES-256-GCM with authentication |
| 30 | const algorithm = 'aes-256-gcm'; |
| 31 | const iv = crypto.randomBytes(16); |
| 32 | const cipher = crypto.createCipheriv(algorithm, key, iv); |
| 33 | |
| 34 | // HTTPS — always enforce TLS 1.2+ |
| 35 | response.setHeader( |
| 36 | 'Strict-Transport-Security', |
| 37 | 'max-age=63072000; includeSubDomains; preload' |
| 38 | ); |
danger
Injection attacks occur when untrusted data is sent to an interpreter as part of a command or query. SQL injection is the most well-known, but injection includes XSS, NoSQL injection, and command injection.
| 1 | // SQL Injection |
| 2 | // BAD — string concatenation |
| 3 | const query = `SELECT * FROM users WHERE id = '${req.query.id}'`; |
| 4 | await db.query(query); // Attacker: 1' OR '1'='1 |
| 5 | |
| 6 | // GOOD — parameterized query |
| 7 | await db.query('SELECT * FROM users WHERE id = $1', [req.query.id]); |
| 8 | |
| 9 | // NoSQL Injection (MongoDB) |
| 10 | // BAD — passing unvalidated input directly |
| 11 | const user = await User.findOne({ |
| 12 | username: req.body.username, |
| 13 | password: req.body.password, |
| 14 | }); // Attacker: { "$gt": "" } matches all |
| 15 | |
| 16 | // GOOD — validate and sanitize first |
| 17 | const schema = z.object({ |
| 18 | username: z.string().min(3).max(50), |
| 19 | password: z.string().min(8).max(100), |
| 20 | }); |
| 21 | const { username, password } = schema.parse(req.body); |
| 22 | const user = await User.findOne({ username, password: hash(password) }); |
| 23 | |
| 24 | // Cross-Site Scripting (XSS) |
| 25 | // BAD — rendering unsanitized user input |
| 26 | app.get('/profile', (req, res) => { |
| 27 | res.send(`<div>${req.query.name}</div>`); // <script>alert('xss')</script> |
| 28 | }); |
| 29 | |
| 30 | // GOOD — escape output |
| 31 | import escape from 'escape-html'; |
| 32 | app.get('/profile', (req, res) => { |
| 33 | res.send(`<div>${escape(req.query.name)}</div>`); |
| 34 | }); |
| 35 | |
| 36 | // Command Injection |
| 37 | // BAD — passing user input to shell |
| 38 | const result = execSync(`ping -c 4 ${req.body.host}`); |
| 39 | // Attacker: ; cat /etc/passwd |
| 40 | |
| 41 | // GOOD — use spawn with array arguments |
| 42 | const result = spawn('ping', ['-c', '4', sanitizeHost(req.body.host)]); |
Insecure design covers architecture-level flaws that cannot be fixed with a simple code change. These are "bugs in the design" rather than "bugs in the implementation." It includes missing threat modeling, lack of rate limiting, and improper trust boundaries.
| 1 | // Missing rate limiting — allows brute force |
| 2 | // BAD — no protection on login |
| 3 | app.post('/login', async (req, res) => { |
| 4 | const user = await authenticate(req.body.email, req.body.password); |
| 5 | // Unlimited attempts! |
| 6 | }); |
| 7 | |
| 8 | // GOOD — rate limit login attempts |
| 9 | import { Ratelimit } from '@upstash/ratelimit'; |
| 10 | |
| 11 | const loginLimiter = new Ratelimit({ |
| 12 | limiter: Ratelimit.slidingWindow(5, '15 m'), // 5 attempts per 15 min |
| 13 | }); |
| 14 | |
| 15 | app.post('/login', async (req, res) => { |
| 16 | const ip = req.ip; |
| 17 | const { success } = await loginLimiter.limit(`login:${ip}`); |
| 18 | |
| 19 | if (!success) { |
| 20 | return res.status(429).json({ |
| 21 | error: 'Too many login attempts. Try again later.', |
| 22 | }); |
| 23 | } |
| 24 | |
| 25 | const user = await authenticate(req.body.email, req.body.password); |
| 26 | }); |
| 27 | |
| 28 | // Account lockout |
| 29 | async function handleFailedLogin(email: string) { |
| 30 | const attempts = await redis.incr(`lockout:${email}`); |
| 31 | await redis.expire(`lockout:${email}`, 900); |
| 32 | |
| 33 | if (attempts >= 5) { |
| 34 | await redis.set(`locked:${email}`, 'true', 'EX', 1800); |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | // STRIDE threat modeling categories: |
| 39 | // Spoofing, Tampering, Repudiation, Info Disclosure, DoS, Elevation |
warning
Security misconfiguration includes default credentials, unnecessary features enabled, overly permissive CORS, missing security headers, and verbose error messages that leak system information.
| 1 | // Security headers checklist |
| 2 | import helmet from 'helmet'; |
| 3 | import cors from 'cors'; |
| 4 | |
| 5 | app.use(helmet()); // Sets 15+ security headers |
| 6 | |
| 7 | // CORS — restrict origins in production |
| 8 | app.use(cors({ |
| 9 | origin: process.env.NODE_ENV === 'production' |
| 10 | ? ['https://example.com'] |
| 11 | : '*', |
| 12 | methods: ['GET', 'POST'], |
| 13 | })); |
| 14 | |
| 15 | // Disable x-powered-by header |
| 16 | app.disable('x-powered-by'); |
| 17 | |
| 18 | // Error handling — don't leak stack traces |
| 19 | app.use((err, req, res, next) => { |
| 20 | console.error(err.stack); |
| 21 | res.status(500).json({ |
| 22 | error: process.env.NODE_ENV === 'production' |
| 23 | ? 'Internal server error' |
| 24 | : err.message, |
| 25 | }); |
| 26 | }); |
| 27 | |
| 28 | // Remove unnecessary routes and features |
| 29 | // Default admin panels, debug endpoints, and unused API routes |
Using components with known vulnerabilities is a common entry point for attacks. Regular dependency auditing and updating is essential.
| 1 | // Regular dependency auditing |
| 2 | npm audit // Check for vulnerabilities |
| 3 | npm audit fix // Auto-fix (check for breaking changes) |
| 4 | npm outdated // List outdated packages |
| 5 | |
| 6 | // Use Snyk for comprehensive scanning |
| 7 | snyk test // Test project for vulnerabilities |
| 8 | snyk monitor // Continuous monitoring |
| 9 | snyk code test // SAST scanning |
| 10 | |
| 11 | // Dependabot config (.github/dependabot.yml) |
| 12 | version: 2 |
| 13 | updates: |
| 14 | - package-ecosystem: "npm" |
| 15 | directory: "/" |
| 16 | schedule: |
| 17 | interval: "weekly" |
| 18 | open-pull-requests-limit: 10 |
| 19 | |
| 20 | // Check for known CVEs before adding dependencies |
| 21 | // https://www.cve.org/ |
| 22 | // https://snyk.io/vuln |
danger
Authentication failures include weak passwords, credential stuffing, session fixation, and missing MFA. Proper session management and credential hygiene are critical.
| 1 | // Session management best practices |
| 2 | import session from 'express-session'; |
| 3 | |
| 4 | app.use(session({ |
| 5 | secret: process.env.SESSION_SECRET, |
| 6 | name: 'custom_sid', // Custom cookie name |
| 7 | cookie: { |
| 8 | httpOnly: true, |
| 9 | secure: process.env.NODE_ENV === 'production', |
| 10 | sameSite: 'lax', |
| 11 | maxAge: 24 * 60 * 60 * 1000, |
| 12 | }, |
| 13 | })); |
| 14 | |
| 15 | // Regenerate session after login |
| 16 | req.session.regenerate((err) => { |
| 17 | req.session.userId = user.id; |
| 18 | // Prevents session fixation |
| 19 | }); |
| 20 | |
| 21 | // Password policy enforcement |
| 22 | const passwordSchema = z.string() |
| 23 | .min(12, 'Password must be at least 12 characters') |
| 24 | .regex(/[A-Z]/, 'Must contain uppercase') |
| 25 | .regex(/[a-z]/, 'Must contain lowercase') |
| 26 | .regex(/[0-9]/, 'Must contain number') |
| 27 | .regex(/[^A-Za-z0-9]/, 'Must contain special character'); |
| 28 | |
| 29 | // Multi-factor authentication |
| 30 | // Use TOTP (time-based one-time passwords) or WebAuthn |
| 31 | // Require MFA for admin accounts and sensitive operations |
Software and data integrity failures cover supply chain attacks, unsigned updates, and insecure CI/CD pipelines. Trusting unverified third-party code or data without integrity checks is the core risk.
| 1 | // Verify package integrity |
| 2 | npm audit signatures // Verify npm package signatures |
| 3 | npm config set sign-git-tag true // Sign your releases |
| 4 | |
| 5 | // Subresource Integrity (SRI) for CDN resources |
| 6 | // <script |
| 7 | // src="https://cdn.example.com/lib.js" |
| 8 | // integrity="sha384-abc123..." |
| 9 | // crossorigin="anonymous" |
| 10 | // ></script> |
| 11 | |
| 12 | // Verify webhook signatures |
| 13 | import crypto from 'crypto'; |
| 14 | |
| 15 | function verifyWebhook(payload: string, signature: string, secret: string) { |
| 16 | const expected = crypto |
| 17 | .createHmac('sha256', secret) |
| 18 | .update(payload) |
| 19 | .digest('hex'); |
| 20 | |
| 21 | return crypto.timingSafeEqual( |
| 22 | Buffer.from(signature), |
| 23 | Buffer.from(expected) |
| 24 | ); |
| 25 | } |
| 26 | |
| 27 | // Use lockfiles (package-lock.json, yarn.lock) |
| 28 | // Pin dependency versions in production |
| 29 | // Audit third-party scripts and CDN resources |
Without proper logging and monitoring, breaches can go undetected for months. Security-relevant events must be logged with sufficient detail for investigation, and alerts must be actionable.
| 1 | // Security event logging middleware |
| 2 | function securityLogger(req, res, next) { |
| 3 | const start = Date.now(); |
| 4 | |
| 5 | res.on('finish', () => { |
| 6 | const logEntry = { |
| 7 | timestamp: new Date().toISOString(), |
| 8 | method: req.method, |
| 9 | path: req.path, |
| 10 | status: res.statusCode, |
| 11 | ip: req.ip, |
| 12 | userAgent: req.headers['user-agent'], |
| 13 | userId: req.session?.userId, |
| 14 | duration: Date.now() - start, |
| 15 | }; |
| 16 | |
| 17 | // Log auth events separately |
| 18 | if (req.path.startsWith('/api/auth')) { |
| 19 | console.log('[AUTH]', JSON.stringify(logEntry)); |
| 20 | // Send to SIEM: Splunk, Datadog, ELK |
| 21 | } |
| 22 | |
| 23 | // Log errors and 4xx/5xx responses |
| 24 | if (res.statusCode >= 400) { |
| 25 | console.warn('[SECURITY]', JSON.stringify(logEntry)); |
| 26 | } |
| 27 | }); |
| 28 | |
| 29 | next(); |
| 30 | } |
| 31 | |
| 32 | // Events to always log: |
| 33 | // - Successful and failed login attempts |
| 34 | // - Privilege changes (role upgrades, permission changes) |
| 35 | // - Sensitive data access (PII, payment info) |
| 36 | // - Configuration changes |
| 37 | // - Admin actions |
| 38 | // - Rate limit violations |
| 39 | // - 4xx and 5xx errors |
| 40 | |
| 41 | // Never log: passwords, tokens, credit card numbers, or secrets |
info
SSRF occurs when an attacker can make the server send requests to internal or unintended destinations. This can lead to accessing internal services, cloud metadata endpoints, or scanning the internal network.
| 1 | // SSRF prevention |
| 2 | import { URL } from 'url'; |
| 3 | |
| 4 | // URL validation — block internal addresses |
| 5 | const BLOCKED_HOSTS = [ |
| 6 | '169.254.169.254', // AWS/GCP/Azure metadata |
| 7 | '127.0.0.1', |
| 8 | 'localhost', |
| 9 | '0.0.0.0', |
| 10 | '10.', |
| 11 | '172.16.', |
| 12 | '172.17.', |
| 13 | '172.18.', |
| 14 | '172.19.', |
| 15 | '172.20.', |
| 16 | '172.21.', |
| 17 | '172.22.', |
| 18 | '172.23.', |
| 19 | '172.24.', |
| 20 | '172.25.', |
| 21 | '172.26.', |
| 22 | '172.27.', |
| 23 | '172.28.', |
| 24 | '172.29.', |
| 25 | '172.30.', |
| 26 | '172.31.', |
| 27 | '192.168.', |
| 28 | ]; |
| 29 | |
| 30 | function isInternalHost(hostname: string): boolean { |
| 31 | return BLOCKED_HOSTS.some(blocked => hostname.startsWith(blocked)); |
| 32 | } |
| 33 | |
| 34 | async function safeFetch(url: string): Promise<Response> { |
| 35 | const parsed = new URL(url); |
| 36 | |
| 37 | if (isInternalHost(parsed.hostname)) { |
| 38 | throw new Error('Blocked internal address'); |
| 39 | } |
| 40 | |
| 41 | // Allow only HTTPS |
| 42 | if (parsed.protocol !== 'https:') { |
| 43 | throw new Error('Only HTTPS URLs are allowed'); |
| 44 | } |
| 45 | |
| 46 | return fetch(url, { |
| 47 | signal: AbortSignal.timeout(5000), // 5 second timeout |
| 48 | }); |
| 49 | } |
| 50 | |
| 51 | // Use allowlist approach when possible |
| 52 | const ALLOWED_DOMAINS = ['api.trusted-service.com', 'cdn.example.com']; |
| 53 | |
| 54 | async function safeFetchAllowlist(url: string): Promise<Response> { |
| 55 | const parsed = new URL(url); |
| 56 | |
| 57 | if (!ALLOWED_DOMAINS.includes(parsed.hostname)) { |
| 58 | throw new Error('Domain not in allowlist'); |
| 59 | } |
| 60 | |
| 61 | return fetch(url); |
| 62 | } |
best practice
| Category | Primary Defense | Tools & Techniques |
|---|---|---|
| A01: Access Control | Deny by default | RBAC, ABAC, middleware, ownership checks |
| A02: Crypto | Strong algorithms | bcrypt, argon2, TLS 1.3, AES-GCM |
| A03: Injection | Parameterized queries | Zod validation, ORM, prepared statements |
| A04: Design | Threat modeling | STRIDE, attack trees, security reviews |
| A05: Misconfiguration | Hardening checklists | Helmet.js, CIS benchmarks, CSP scanners |
| A06: Components | Dependency scanning | npm audit, Snyk, Dependabot, Trivy |
| A07: Auth | MFA + rate limiting | TOTP, WebAuthn, bcrypt, session mgmt |
| A08: Integrity | Signature verification | SRI, package signing, webhook verification |
| A09: Logging | Centralized SIEM | ELK, Splunk, Datadog, alerting rules |
| A10: SSRF | Allowlist + validation | URL sanitization, IP blocking, timeouts |
- Run OWASP ZAP or Burp Suite scans as part of your CI/CD pipeline.
- Use a web application firewall (WAF) as a defense-in-depth layer, not as your only defense.
- Conduct regular penetration tests and security audits.
- Keep an inventory of all dependencies and their known vulnerabilities.
- Implement a bug bounty program or vulnerability disclosure policy.
- Train your development team on OWASP Top 10 annually.
- Use SAST (Static Analysis) and DAST (Dynamic Analysis) tools in your pipeline.
- Define and enforce a Software Security Development Lifecycle (SSDL).
best practice