SQL Injection in 2026: Why the Oldest Attack Still Works
SQL injection was first documented in 1998. It's been in the OWASP Top 10 since the list was created. Every web framework tutorial warns about it. Parameterized queries have been available in every major language for over two decades. And yet, here we are in 2026, and SQL injection is still responsible for major data breaches every single year.
The reason isn't that developers are ignorant. It's that modern applications create the exact conditions where SQL injection sneaks through: complex ORMs that make you feel safe until you need a raw query, dynamic search filters that are awkward to parameterize, and legacy codebases where "just concatenate the string" was fine when the code was written in 2009.
I'm going to cover the basics because someone reading this needs the basics. But I'm also going to cover the scenarios where experienced developers get caught — because those are the ones that actually end up in production.
The Classic Attack (Yes, It Still Works)
Let's get this out of the way. Here's the textbook example:
// ❌ VULNERABLE — string concatenation with user input
const query = `SELECT * FROM users WHERE email = '${userEmail}'`;
// Attacker inputs: ' OR '1'='1' --
// Resulting query: SELECT * FROM users WHERE email = '' OR '1'='1' --'
// Returns ALL users!If this looks obvious to you, great. But consider: would you catch it in a 500-line file during a Friday afternoon code review? What about when it's not
emailDefense #1: Parameterized Queries (Non-Negotiable)
This is the fix. Not "a" fix — THE fix. Parameterized queries structurally separate the SQL command from the data, making it impossible for user input to be interpreted as SQL.
// ✅ PostgreSQL with pg — $1, $2 are parameter placeholders
const result = await pool.query(
'SELECT * FROM users WHERE email = $1 AND status = $2',
[userEmail, 'active']
);
// ✅ MySQL with mysql2 — ? placeholders
const [rows] = await pool.execute(
'SELECT * FROM users WHERE email = ? AND status = ?',
[userEmail, 'active']
);The database driver handles escaping and quoting. The user's input is always treated as data, never as SQL syntax. Even if someone submits
' OR '1'='1Defense #2: ORM with Type Safety
ORMs abstract away SQL entirely for most operations, which eliminates the injection surface for standard CRUD operations.
// ✅ Prisma — type-safe, no raw SQL by default
const user = await prisma.user.findUnique({
where: { email: userEmail }
});
// ✅ When you need raw SQL in Prisma, template literals are automatically parameterized
const users = await prisma.$queryRaw`
SELECT * FROM users WHERE email = ${userEmail}
`;Here's where it gets nuanced. ORMs protect you for the 90% of queries that are straightforward. But every non-trivial application eventually needs queries the ORM can't express cleanly — complex joins, window functions, dynamic search with optional filters, full-text search, or database-specific features. When that happens, developers reach for raw queries. And that's where the guard drops.
Gotcha: Prisma's
$queryRaw$queryRawUnsafe()$queryRawUnsafeDefense #3: Input Validation (Your Second Layer)
Parameterized queries prevent injection. Input validation prevents nonsense from even reaching your database. These are complementary defenses, not alternatives.
import { z } from 'zod';
const SearchSchema = z.object({
email: z.string().email().max(255),
limit: z.number().int().min(1).max(100).default(20),
offset: z.number().int().min(0).default(0),
});
app.get('/api/users', async (req, res) => {
const params = SearchSchema.parse(req.query); // Throws if invalid
const users = await db.query(
'SELECT * FROM users WHERE email = $1 LIMIT $2 OFFSET $3',
[params.email, params.limit, params.offset]
);
});Zod (or Joi, or yup) validates that the input matches expected types and formats before it goes anywhere near your database. An email field that doesn't look like an email gets rejected. A limit of -1 or 999999 gets rejected. This doesn't replace parameterized queries — it complements them by catching malformed input early and giving better error messages than a database constraint violation.
The dynamic query trap: The hardest SQL injection scenarios I encounter involve dynamic search endpoints where different combinations of filters produce different WHERE clauses. The temptation is to build the query string conditionally:
// ❌ This pattern is where smart developers get caught
let query = 'SELECT * FROM products WHERE 1=1';
if (filters.category) query += ` AND category = '${filters.category}'`;
if (filters.minPrice) query += ` AND price >= ${filters.minPrice}`;The fix is to build the parameterized query dynamically:
// ✅ Dynamic but safe — parameters array grows with the query
let query = 'SELECT * FROM products WHERE 1=1';
const params = [];
let paramIndex = 1;
if (filters.category) {
query += ` AND category = $${paramIndex++}`;
params.push(filters.category);
}
if (filters.minPrice) {
query += ` AND price >= $${paramIndex++}`;
params.push(filters.minPrice);
}
const result = await pool.query(query, params);It's more code, but it's safe code. Query builders like Knex.js handle this pattern more elegantly if you're doing it frequently.
Defense #4: Least Privilege Database Users
Even with perfect parameterization, defense in depth matters. If your application's database user only has SELECT, INSERT, and UPDATE permissions, a successful injection (through some unforeseen vector) can't DROP TABLE or access other databases.
-- Create app-specific DB user with minimal permissions
CREATE USER app_user WITH PASSWORD 'strongpassword';
GRANT SELECT, INSERT, UPDATE ON users, posts TO app_user;
-- NO: GRANT ALL PRIVILEGES — this is what attackers exploit
-- NO: Don't use root/postgres/sa for your app connectionGotcha: If you use an ORM with migration capabilities (Prisma, TypeORM, Sequelize), the migration user needs different permissions than the application user. Don't run migrations with the application user (too few permissions) and don't run the application with the migration user (too many permissions). Separate the concerns.
Why ORMs Aren't a Complete Solution
I want to be clear: using an ORM is good practice. But ORMs create a false sense of security when developers believe "I use Prisma, so SQL injection is impossible." It's not impossible — it's just less likely for the common case. The injection risk moves from everyday CRUD operations to the edge cases: raw queries, dynamic filters, stored procedures, and any place where the ORM's abstraction leaks.
Treat your ORM as one layer of defense. Pair it with input validation, parameterized raw queries when you need them, and a least-privilege database user. If you're also handling auth tokens in your application, the JWT security mistakes we covered are closely related — a compromised JWT combined with SQL injection is how single vulnerabilities become full breaches.
What I'd Tell My Past Self
The uncomfortable truth about SQL injection is that it persists not because it's hard to prevent, but because it's hard to prevent consistently across every query in an application that evolves over years with multiple developers. The fix for any individual query is trivial — use a parameterized query. The fix for an organization is harder: code review standards, linting rules that flag string concatenation in SQL contexts, ORM conventions, and a culture where "I'll parameterize it later" is treated the same as "I'll add the lock later."
Parameterize every query. No exceptions. No "it's just an internal tool." No "the input is already validated." Belt and suspenders.
Discussion
0 comments
Share your thoughts
No comments yet. Be the first to share your thoughts!