
Update: OWASP has since published the Top 10:2025. This post covers the 2021 edition, which was current when it was written. Most of the advice still applies; the 2025 list reorders some risks and adds Software Supply Chain Failures and Mishandling of Exceptional Conditions. Read the 2025 breakdown.
Most breaches don't need a genius attacker.
They need one endpoint that forgot to check who's calling. One query built by gluing strings together. One dependency nobody updated.
In the data behind the OWASP Top 10, broken access control showed up more than 318,000 times, more than any other risk. And IBM put the average cost of a data breach in 2023 at $4.45 million.
The OWASP Top 10 is the shortlist of what goes wrong most often. Here are all ten, with a way to prevent each.
What the OWASP Top 10 Is
The Open Worldwide Application Security Project (OWASP) is a non-profit that publishes free security guidance. Its Top 10 ranks the most critical web application security risks, based on data from real application testing plus a community survey.
It isn't a complete checklist. It's the place to start.
The OWASP Top 10 (2021)
A01:2021 – Broken Access Control
Up from fifth place to first. Users can act outside their permissions: reading other users' data, or calling admin endpoints. It had the most occurrences in OWASP's dataset, over 318,000, and more mapped weaknesses (34 CWEs) than any other category.
Prevention: check permissions on every request, on the server.
// Express.js middleware for role-based access control
const checkPermission = (requiredRole) => {
return (req, res, next) => {
if (!req.user || !req.user.roles.includes(requiredRole)) {
return res.status(403).json({ error: "Access denied" });
}
next();
};
};
app.get("/admin/users", checkPermission("admin"), (req, res) => {
// Handle admin-only route
});
A02:2021 – Cryptographic Failures
Previously called "Sensitive Data Exposure." The focus moved from the symptom (data leaked) to the cause: missing or weak cryptography.
Prevention: hash passwords with a slow, salted algorithm. Never store them in plain text.
const bcrypt = require("bcrypt");
async function hashPassword(password) {
const saltRounds = 12;
return bcrypt.hash(password, saltRounds);
}
async function verifyPassword(password, hash) {
return bcrypt.compare(password, hash);
}
A03:2021 – Injection
Down to third, but still common. Untrusted input ends up executed as a query or command. This category now includes Cross-Site Scripting (XSS).
Prevention: use parameterized queries or an ORM. Never build queries from strings.
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
async function getUser(email) {
// Prisma parameterizes this, so it's safe from SQL injection
return prisma.user.findUnique({ where: { email } });
}
A04:2021 – Insecure Design
New in 2021. Some flaws can't be fixed by better code because the design itself is unsafe, like a login form with no limit on attempts. The fix starts earlier: threat modeling and secure design patterns.
Prevention: design abuse limits in from the start.
const rateLimit = require("express-rate-limit");
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per window
message: "Too many login attempts, please try again later",
});
app.use("/login", loginLimiter);
A05:2021 – Security Misconfiguration
Up from sixth. Default passwords, verbose error pages, missing security headers. OWASP tested 90% of applications for some form of misconfiguration, with an average incidence rate of 4.51%.
Prevention: secure defaults, applied the same way everywhere.
const express = require("express");
const helmet = require("helmet");
const session = require("express-session");
const app = express();
app.use(helmet()); // sensible security headers
app.disable("x-powered-by");
app.use(
session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { secure: true, httpOnly: true, sameSite: "strict" },
}),
);
A06:2021 – Vulnerable and Outdated Components
Previously "Using Components with Known Vulnerabilities," up from ninth. Your code can be perfect and still be exploited through a library.
Prevention: scan dependencies continuously and update them.
npm audit
npm audit fix
# Continuous monitoring with Snyk
snyk test
snyk monitor
A07:2021 – Identification and Authentication Failures
Previously "Broken Authentication," down from second. Standard frameworks have made it less common, but weak passwords, missing multi-factor authentication and poor session handling still cause breaches.
Prevention: use a proven auth library, and add a second factor on top of passwords.
// Password verification with Passport.js.
// Add a second factor (TOTP or WebAuthn) after this step for MFA.
const passport = require("passport");
const { Strategy: LocalStrategy } = require("passport-local");
passport.use(
new LocalStrategy(async (username, password, done) => {
const user = await User.findOne({ username });
if (!user) return done(null, false);
const isValid = await user.verifyPassword(password);
if (!isValid) return done(null, false);
return done(null, user);
}),
);
A08:2021 – Software and Data Integrity Failures
New in 2021. Code or data is trusted without checking it hasn't been tampered with: unpinned dependencies, unsigned updates, CI/CD pipelines that pull whatever is latest.
Prevention: pin exactly what you install, and verify what you load.
# Install exactly what package-lock.json pins, and fail if it doesn't match
npm ci
<!-- Subresource Integrity: the browser refuses the script if its hash changes -->
<script
src="https://cdn.example.com/lib.min.js"
integrity="sha384-REPLACE_WITH_THE_FILE_HASH"
crossorigin="anonymous"
></script>
A09:2021 – Security Logging and Monitoring Failures
Previously "Insufficient Logging & Monitoring," up from tenth. If you don't log security events, you can't detect an attack or investigate one.
Prevention: log who did what, when, from where, and alert on the suspicious parts.
const winston = require("winston");
const logger = winston.createLogger({
level: "info",
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: "error.log", level: "error" }),
new winston.transports.File({ filename: "combined.log" }),
],
});
app.use((req, res, next) => {
logger.info({
method: req.method,
path: req.path,
ip: req.ip,
userAgent: req.get("user-agent"),
});
next();
});
A10:2021 – Server-Side Request Forgery (SSRF)
Added from the community survey, where it ranked first. The server fetches a URL the attacker controls, which can reach internal services or cloud metadata endpoints.
Prevention: only fetch from an allowlist of hosts.
const allowedHosts = ["api.example.com", "cdn.example.com"];
const isAllowedUrl = (url) => {
try {
const { protocol, hostname } = new URL(url);
return protocol === "https:" && allowedHosts.includes(hostname);
} catch {
return false;
}
};
app.get("/proxy", (req, res) => {
if (!isAllowedUrl(req.query.url)) {
return res.status(400).json({ error: "Invalid URL" });
}
// Proceed with the safe URL
});
Putting It Into Practice
- Audit regularly: combine automated scanning with manual review, and keep dependencies current.
- Design for security: threat-model new features and follow least privilege by default.
- Monitor continuously: log security events, alert on suspicious patterns, and review them.
Key Takeaways
- Broken access control is the most common risk. Check permissions on every request, server-side.
- Hash passwords with a slow, salted algorithm, and never build queries from strings.
- Rate-limit sensitive actions like login, by design.
- Ship secure defaults and scan dependencies continuously.
- Pin and verify what you install and load.
- Log security events, and only fetch URLs from an allowlist.
Final Thoughts
Security isn't a feature you add at the end.
It's a set of small, boring habits applied everywhere. The OWASP Top 10 tells you which habits to build first.