The OWASP Top 10, explained with the code that causes each one
The Top 10 is a list of categories, not bugs. Here is what each one looks like in a real codebase.
SQL injection happens when user input is concatenated into a query so the database interprets it as code. Prevent it by always using parameterised queries or an ORM's safe query builder, validating identifiers against an allow-list when table or column names must be dynamic, and running the application with a least-privilege database user.
// Vulnerable
const q = `SELECT * FROM users WHERE email = '${email}' AND active = true`;
// email = "' OR '1'='1' --"
// becomes: SELECT * FROM users WHERE email = '' OR '1'='1' --' AND active = true
// Safe: the driver sends the query and the values separately
const rows = await db.query(
"SELECT * FROM users WHERE email = $1 AND active = true",
[email]
);With parameters, the database receives the query structure first and the values second. There is no parsing step in which a value could become syntax, which is why this is a complete fix rather than a filter that might miss something.
// Unsafe even with an ORM
await prisma.$queryRawUnsafe(`SELECT * FROM users WHERE id = ${id}`);
// Safe: tagged template binds parameters
await prisma.$queryRaw`SELECT * FROM users WHERE id = ${id}`;
// Dynamic ordering: allow-list, never interpolate
const columns = { name: "name", created: "created_at" } as const;
const orderBy = columns[input.sort] ?? "created_at";Document databases are vulnerable too, when a request body is passed directly into a query and can smuggle operators.
// Vulnerable: body is { "email": "[email protected]", "password": { "$ne": null } }
await users.findOne({ email: body.email, password: body.password });
// matches any user whose password is not null
// Safe: validate types before querying
const { email, password } = loginSchema.parse(body); // both must be strings
await users.findOne({ email, password: hash(password) });The fix is the same principle: never pass unvalidated structures into a query. A schema that requires a string rejects the object carrying the operator.
Closely related. A prepared statement is compiled once and executed with different parameters; parameterisation is the property that keeps values separate from the query structure. Both prevent injection.
No. Escaping depends on getting the character set, quoting context and database dialect exactly right. Parameterisation removes the problem instead of managing it.
Only if they use parameters internally. A stored procedure that builds dynamic SQL from its arguments is exactly as vulnerable as application code doing the same.
You cannot — parameters bind values, not identifiers. Map user input to a fixed allow-list of permitted names in code.
ROVQIX Engineering
Engineering team, ROVQIX
The ROVQIX engineering team builds and maintains web platforms, APIs and infrastructure for clients across SaaS, ecommerce and enterprise. These notes come out of real production work — deploys, incidents, migrations and audits.
ROVQIXdesigns and builds production web platforms — Next.js front ends, Node.js APIs and the infrastructure behind them. Tell us what you're building and we'll scope it with you.
The Top 10 is a list of categories, not bugs. Here is what each one looks like in a real codebase.
An API has no UI to hide behind. Every endpoint is directly reachable, and that is the correct way to think about securing one.
Everyone runs EXPLAIN. Fewer people read the row estimates, which is where the actual answer usually is.
No spam. Just the occasional case study and craft breakdown.