The List Nobody Reads Until Something Breaks#
Every few years, OWASP publishes a list of the 10 most critical web application security risks. Every few years, developers nod at it, bookmark it, and go back to writing code that’s vulnerable to at least three of them. I know because I’ve done it.
The 2025 edition analyzed 2.8 million applications. These aren’t theoretical attacks — they’re the vulnerabilities that show up in real codebases, at real companies, every day. And most of them are preventable with patterns you probably already know.
1. Broken Access Control (Still #1)#
The most common vulnerability for the fourth year running. It’s not about hackers breaking encryption — it’s about your code checking if someone is logged in but not checking if they’re allowed to see what they’re asking for.
// This checks authentication. It does NOT check authorization.
app.get("/api/users/:id/orders", authenticate, async (req, res) => {
const orders = await db.orders.findMany({ where: { userId: req.params.id } });
return res.json(orders);
// Any logged-in user can view ANY user's orders by changing the ID in the URL
});
The fix is one condition:
if (req.user.id !== req.params.id && req.user.role !== "admin") {
return res.status(403).json({ error: "Forbidden" });
}
This is called IDOR (Insecure Direct Object Reference), and it’s embarrassingly common. Change /api/users/123/orders to /api/users/456/orders and see if the API lets you in. If it does, that’s a broken access control.
SSRF (Server-Side Request Forgery) got merged into this category for 2025 — it’s another flavor of the same problem: your server making requests it shouldn’t because nobody validated the target.
2. Security Misconfiguration (Up from #5)#
Default credentials. Verbose error messages. Debug mode in production. Stack traces in API responses. This moved up to #2 because it’s gotten more prevalent, not less.
// BAD: production error handler that leaks everything
app.use((err, req, res, next) => {
res
.status(500)
.json({ error: err.message, stack: err.stack, query: err.sql });
});
// GOOD: log details internally, return nothing useful to the attacker
app.use((err, req, res, next) => {
logger.error("INTERNAL_ERROR", { error: err.message, stack: err.stack });
res.status(500).json({
error: { code: "INTERNAL_ERROR", message: "Something went wrong" },
});
});
One line of middleware fixes half of this: app.use(helmet()). The helmet library sets security headers (X-Content-Type-Options, HSTS, X-Frame-Options) that browsers use to protect your users. It takes 10 seconds to add and prevents a whole class of attacks.
3. Software Supply Chain Failures (Expanded)#
This used to be “Vulnerable and Outdated Components” — basically, update your packages. The 2025 version broadened it to cover the entire supply chain: compromised packages, typosquatting (lodsah instead of lodash), build pipeline attacks, and lockfile manipulation.
The npm ecosystem has had multiple incidents where popular packages were compromised by attackers gaining access to maintainer accounts. Your code might be perfectly secure, but if one of your 400 dependencies isn’t, you’re vulnerable.
# Audit your dependencies
npm audit
# Use the lockfile exactly (no surprises)
npm ci # not npm install
# Automate security updates
# GitHub Dependabot or Renovate
The rule: lock your dependencies, audit regularly, and don’t blindly merge update PRs without checking what changed. Every package you install is code you’re trusting to run in your production environment.
4. Cryptographic Failures (Down from #2)#
Still important, just less prevalent — frameworks have gotten better at defaults. But developers still store passwords with MD5, transmit data over HTTP, and hardcode API keys in source code.
// If you're doing this in 2025, we need to talk
const hash = crypto.createHash("md5").update(password).digest("hex");
// This is the minimum acceptable approach
import bcrypt from "bcrypt";
const hash = await bcrypt.hash(password, 12);
bcrypt, scrypt, or argon2 for passwords. TLS for everything in transit. Environment variables (not source code) for secrets. That covers 90% of cryptographic failures.
5. Injection (Down from #3)#
ORMs have helped push this down the rankings, but injection is still alive. SQL injection, command injection, and NoSQL injection all follow the same pattern: user input gets interpreted as code.
// The classic SQL injection — user input concatenated into a query
const query = `SELECT * FROM users WHERE email = '${req.body.email}'`;
// Input: ' OR '1'='1' --
// Result: returns ALL users
// The fix: parameterized queries
const result = await db.query("SELECT * FROM users WHERE email = $1", [
req.body.email,
]);
// Input is treated as data, never as code
If you’re using an ORM (Prisma, Sequelize, ActiveRecord), parameterization is handled for you. If you’re writing raw queries, parameterize them. Always. No exceptions.
Command injection is the scarier cousin:
// BAD: shell interprets user input
exec(`ping ${req.query.host}`); // input: google.com; rm -rf /
// GOOD: no shell interpretation
execFile("ping", ["-c", "4", req.query.host]);
6. Insecure Design#
This isn’t a code bug — it’s a design bug. No amount of good implementation saves a fundamentally insecure design. A password reset that only requires an email address. A login endpoint with no rate limiting . A purchase API that accepts negative quantities (free refunds!).
The fix is threat modeling during design: “how could someone abuse this?” If you only test happy paths, you only catch happy bugs.
7. Authentication Failures#
Weak passwords, no account lockout, session tokens in URLs, sessions that don’t invalidate on logout. Authentication is one of those things that’s easy to get mostly right and very hard to get completely right.
// Rate limit login attempts — 5 per 15 minutes
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
message: { error: "Too many login attempts" }
});
app.post("/login", loginLimiter, loginHandler);
// Secure session cookies
cookie: {
httpOnly: true, // JavaScript can't read it (prevents XSS theft)
secure: true, // HTTPS only
sameSite: 'strict', // prevents CSRF
maxAge: 3600000 // 1 hour, not forever
}
And enforce MFA for anything sensitive. When I set up Authentik in my homelab with mandatory 2FA, it wasn’t because I expected targeted attacks on my Grafana dashboard. It was because a compromised password without 2FA means access to everything behind the SSO. With 2FA, a stolen password is useless on its own.
8. Software or Data Integrity Failures#
Trusting data or software without verification. Auto-updates without signatures. Deserializing user-provided data. Loading scripts from CDNs without integrity checks.
<!-- Anyone can modify what this CDN serves. Verify it. -->
<script
src="https://cdn.example.com/lib.js"
integrity="sha384-abc123..."
crossorigin="anonymous"
></script>
Subresource Integrity (SRI) tells the browser: “only execute this script if its hash matches what I expect.” If the CDN is compromised, the tampered script won’t run.
9. Security Logging and Alerting Failures#
You can’t respond to attacks you can’t see. If your application doesn’t log authentication failures, authorization violations, and input validation errors — you won’t know you’re being attacked until it’s too late.
This connects directly to the monitoring stack . Prometheus scraping metrics and AlertManager routing alerts aren’t just for infrastructure health — they should catch security patterns too. Repeated 401s from one IP. A spike in 403s. Unusual login times. These are the signals that distinguish an attack from normal traffic.
// Log security events with enough context to investigate
logger.warn("AUTH_FAILURE", {
email: req.body.email,
ip: req.ip,
endpoint: req.path,
timestamp: new Date().toISOString(),
});
Log it, aggregate it, alert on it. And don’t log the password they attempted — that’s PII in your log files.
10. Mishandling of Exceptional Conditions (New)#
The brand new category for 2025. What happens when things go wrong? If your auth service throws an exception and your code continues executing, the user gets access without authentication. That’s called “failing open,” and it’s exactly as bad as it sounds.
// BAD: fail open — exception means access granted
try {
await checkPermission(user, resource);
} catch (e) {
// error swallowed, execution continues
}
// GOOD: fail closed — exception means denied
let authorized = false;
try {
authorized = await checkPermission(user, resource);
} catch (e) {
logger.error("AUTH_CHECK_FAILED", { error: e.message });
authorized = false; // explicit denial
}
if (!authorized) return res.status(403).json({ error: "Forbidden" });
The principle: fail closed. When in doubt, deny. When an error occurs, deny. When the auth service is down, deny. The temporary inconvenience of a user getting a 403 is infinitely better than the permanent consequences of an unauthorized access.
The Uncomfortable Truth#
Most of these vulnerabilities aren’t sophisticated. They’re not zero-day exploits or advanced persistent threats. They’re missing if statements, default passwords, and unescaped user input. The OWASP Top 10 hasn’t changed dramatically in 20 years because the fundamental mistakes haven’t changed either.
The good news: every one of them is preventable with patterns you already know. Parameterized queries. Input validation. Authentication middleware. Rate limiting. Security headers. Error handling. None of this is new. It just needs to be done consistently, on every endpoint, in every service, every time.
