OWASP Top 10: Web Security Vulnerabilities Explained
A practical walkthrough of the current OWASP Top 10 with code examples, real attack patterns, and defense strategies for each vulnerability.

Security tends to be the thing developers plan to do "later." Ship the feature first, deploy, and handle security "when there's time." That time usually arrives after a breach.
OWASP (Open Web Application Security Project) is a nonprofit focused on web security. Their "Top 10" list catalogs the most common and dangerous web vulnerabilities. The list below is based on the 2021 edition — the most widely referenced version. OWASP periodically updates the list, so check owasp.org for the latest revision. If you build web applications, you should know every item on this list.
A01: Broken Access Control
Number one. The most common and most dangerous. This is when users can step outside their own permissions — accessing other users' data or using admin features they shouldn't have.
Classic pattern:
// Changing the user ID in the URL reveals someone else's data
GET /api/users/1234/profile → your profile
GET /api/users/1235/profile → someone else's profile, no questions asked
This is called IDOR (Insecure Direct Object Reference). It's disturbingly common. It happens when APIs don't check "Does the requesting user actually have permission to access this resource?"
Defense:
// BAD — returns data based on ID alone
app.get('/api/orders/:id', async (req, res) => {
const order = await Order.findById(req.params.id);
res.json(order);
});
// GOOD — verifies ownership
app.get('/api/orders/:id', async (req, res) => {
const order = await Order.findById(req.params.id);
if (order.userId !== req.user.id) {
return res.status(403).json({ error: 'Forbidden' });
}
res.json(order);
});
The principle is deny by default. Anything not explicitly permitted is blocked. Design access control as an allowlist, not a blocklist.
A02: Cryptographic Failures
Failing to properly protect sensitive data. Storing passwords in plaintext, transmitting sensitive data over HTTP, using weak encryption algorithms.
Common mistakes:
- Hashing passwords with SHA-256 (without salt, or instead of bcrypt/Argon2)
- Hardcoding API keys in source code
- Supporting old TLS versions (1.0, 1.1)
- Exposing stack traces or DB info in error messages
// BAD
const hashedPassword = crypto.createHash('sha256').update(password).digest('hex');
// GOOD — bcrypt with automatic salt
import bcrypt from 'bcrypt';
const hashedPassword = await bcrypt.hash(password, 12);
A03: Injection
User input gets executed as part of a command or query. SQL injection is the most famous, but NoSQL injection, OS command injection, and LDAP injection all belong here.
Classic SQL injection:
// BAD — user input directly in the query
const query = `SELECT * FROM users WHERE email = '${email}'`;
// If email is ' OR '1'='1 ?
// SELECT * FROM users WHERE email = '' OR '1'='1'
// → returns every user
// GOOD — parameterized query
const query = 'SELECT * FROM users WHERE email = $1';
const result = await db.query(query, [email]);
ORMs use parameterized queries by default, so they're relatively safe against SQL injection. But when writing raw queries — $queryRaw in Prisma, query in TypeORM — string interpolation opens an injection hole.
XSS (Cross-Site Scripting) is also a form of injection:
<!-- BAD — user input rendered directly as HTML -->
<div>{userInput}</div>
<!-- What if userInput is <script>alert('hacked')</script>? -->
<!-- React escapes JSX by default, but... -->
<!-- dangerouslySetInnerHTML bypasses that protection -->
<div dangerouslySetInnerHTML={{ __html: userInput }} />
React's automatic escaping in JSX makes it relatively safe against XSS. But dangerouslySetInnerHTML removes that protection. The name has "dangerously" in it for a reason.
A04: Insecure Design
Not a code-level bug but a design-level flaw. Password reset questions like "mother's maiden name" when that info is publicly available on social media — that's a design problem.
Another example: responding with "Email not registered" vs "Wrong password" during login tells attackers which emails have accounts. Make the response identical regardless.
This is about threat modeling at the design stage. "If this feature gets abused, what scenarios are possible?"
A05: Security Misconfiguration
Default passwords left unchanged, unnecessary ports open, directory listing enabled, debug mode deployed to production.
Frequent offenders:
- Cloud S3 buckets set to public
.envfiles accessible from the web- Spring Boot Actuator endpoints exposed without auth
- CORS set to
* - Error pages revealing server version and framework info
// BAD
app.use(cors({ origin: '*' }));
// GOOD
app.use(cors({
origin: ['https://yourdomain.com'],
methods: ['GET', 'POST'],
credentials: true
}));
Security headers matter too. Content-Security-Policy, X-Content-Type-Options, Strict-Transport-Security — set them. Next.js lets you configure security headers in next.config.js.
A06: Vulnerable and Outdated Components
One npm package can pull in hundreds of dependencies. If any one of them has a known vulnerability, you have a problem. The 2021 Log4Shell incident is the textbook example — a single vulnerability in log4j shook the entire industry.
# Check npm project for vulnerabilities
npm audit
# Attempt automatic fixes
npm audit fix
# Set up GitHub Dependabot or Snyk in your CI
# to catch vulnerabilities at the PR level
Delaying dependency updates creates technical debt. When you finally try to update everything at once, compatibility issues explode. Automated dependency updates (Dependabot, Renovate) are worth the setup.
A07: Identification and Authentication Failures
Weak passwords allowed, no brute force protection, poor session management.
// Password policy validation
function validatePassword(password: string): boolean {
if (password.length < 12) return false;
if (!/[A-Z]/.test(password)) return false;
if (!/[a-z]/.test(password)) return false;
if (!/[0-9]/.test(password)) return false;
return true;
}
This alone isn't enough. You should also check if a password appears in known breach databases — the Have I Been Pwned API is useful here. Rate limiting on login attempts is essential. Something like temporary lockout after 5 failures.
Common session management mistakes:
- Not reissuing the session ID after login (vulnerable to session fixation)
- Not invalidating server-side sessions on logout
- JWT tokens with excessively long or no expiration
A08: Software and Data Integrity Failures
No integrity verification in CI/CD pipelines, auto-updates, or serialization. The SolarWinds attack is the textbook case — malicious code was injected into the build system and distributed through the normal update path.
// Insecure deserialization — deserializing user input directly
// BAD
const userData = JSON.parse(req.body.data);
// JSON is relatively safe, but...
// The really dangerous one is eval or Function constructor
// BAD — never do this
const result = eval(userInput);
Don't trust unsigned serialized data. Lock down CI/CD pipeline access. Verify package integrity (lock files + integrity hashes).
A09: Security Logging and Monitoring Failures
An attack is happening and nobody knows. Logs aren't being collected, or they're collected but nobody reads them, or alerts aren't configured.
What to log:
- Login success/failure (especially repeated failures)
- Access control failures
- Input validation failures
- Admin actions (permission changes, etc.)
// Login failure logging
logger.warn('Login failed', {
email: maskEmail(email),
ip: req.ip,
userAgent: req.headers['user-agent'],
timestamp: new Date().toISOString()
});
Don't log passwords or credit card numbers. This seems obvious but the mistake is surprisingly common. Sensitive data must be masked or excluded entirely.
A10: Server-Side Request Forgery (SSRF)
Tricking the server into making requests to URLs specified by an attacker. This lets attackers reach internal services that are only accessible from within the network.
// BAD — server fetches a user-provided URL
app.get('/fetch', async (req, res) => {
const response = await fetch(req.query.url as string);
const data = await response.text();
res.send(data);
});
// url=http://169.254.169.254/latest/meta-data/
// → accesses AWS metadata service. IAM credentials can be stolen
The AWS instance metadata service (169.254.169.254) is a prime target. The 2019 Capital One breach was an SSRF + metadata service combination.
Defense:
- Validate user-provided URLs against an allowlist
- Block requests to internal IP ranges (10.x, 172.16.x, 192.168.x, 169.254.x)
- Use AWS IMDSv2 (token-based, harder to exploit via SSRF)
Where to Start
Tackling all ten at once is overwhelming. Prioritize:
- Injection defense — Use parameterized queries. If you're using an ORM, audit only the raw query sections
- Access control — Verify every API endpoint has permission checks
- Dependency auditing — Add
npm auditto CI and enable Dependabot - Security headers — Configure CSP, HSTS, etc.
- MFA — At minimum for admin accounts
Perfect security doesn't exist. But covering the basics in the OWASP Top 10 blocks the vast majority of attacks. This list isn't "advanced security techniques" — it's closer to "the bare minimum."