SQL injection in AI-generated ORM code: what the model gets wrong
By Seaworthy · March 3, 2026
Photo by Mohammad Rahmani on Unsplash
Why the model reaches for string interpolation
ORMs were invented specifically to stop developers from writing raw SQL by hand. Parameterised queries, prepared statements, query builder APIs: the whole point is that user input never touches the SQL string itself. And yet, every week, I see AI-generated code that looks like this:
// Generated by an LLM when asked to "filter users by search term"
const users = await db.query(
`SELECT * FROM users WHERE name LIKE '%${searchTerm}%' OR email LIKE '%${searchTerm}%'`
);
That is textbook SQL injection. The template literal drops searchTerm straight into the query string with no escaping, no binding, nothing. If searchTerm is %' OR '1'='1, every row in the table comes back. If it is '; DROP TABLE users; --, you already know how that ends.
Why does this happen? My working theory: LLMs are trained on enormous amounts of tutorial code, Stack Overflow answers, and blog posts. A lot of that code is old, written before query builders were ubiquitous, or written quickly to illustrate a concept rather than ship to production. The model has seen SELECT * FROM users WHERE name = '${input}' ten thousand times. It reproduces the pattern because statistically it fits.
The model also tends to reach for string interpolation when the query is complex. Simple lookups often come out fine. Ask for something with a dynamic ORDER BY or a multi-column LIKE filter and the model loses its footing.
Three patterns that keep showing up
1. Raw query with interpolated input
The most direct form. The developer asks for a search feature; the model produces a raw query method with a template literal.
// Prisma — but bypassing everything Prisma exists to do
const results = await prisma.$queryRaw(
`SELECT * FROM products WHERE category = '${category}' AND price < ${maxPrice}`
);
$queryRaw accepts a tagged template literal for a reason: Prisma sanitises values passed through the tagged form. Passing an ordinary string instead throws away that protection entirely. The correct version:
const results = await prisma.$queryRaw`
SELECT * FROM products
WHERE category = ${category}
AND price < ${maxPrice}
`;
One character of difference in how the argument is passed. Zero difference in how it looks to a distracted reviewer.
2. Dynamic ORDER BY clause
This one catches people out even when they are otherwise careful. Query builder APIs typically parameterise WHERE values but not column identifiers, because SQL does not allow column names to be bound as parameters.
// TypeORM — user controls the sort column
const sortColumn = req.query.sort; // e.g. "name", but could be anything
const users = await userRepository
.createQueryBuilder('user')
.orderBy(`user.${sortColumn}`, 'ASC')
.getMany();
If sortColumn is name, this works fine. If it is name; DROP TABLE users; --, the query builder faithfully injects that string into the ORDER BY clause. LLMs produce this pattern constantly because they correctly identify that TypeORM needs a string for orderBy and reach for the obvious solution.
The fix is an allowlist:
const ALLOWED_SORT_COLUMNS = ['name', 'email', 'createdAt'] as const;
type SortColumn = typeof ALLOWED_SORT_COLUMNS[number];
function isSortColumn(value: unknown): value is SortColumn {
return ALLOWED_SORT_COLUMNS.includes(value as SortColumn);
}
const rawSort = req.query.sort;
const sortColumn: SortColumn = isSortColumn(rawSort) ? rawSort : 'createdAt';
const users = await userRepository
.createQueryBuilder('user')
.orderBy(`user.${sortColumn}`, 'ASC')
.getMany();
More code, but the surface area for injection is gone.
3. LIKE wildcards with unsanitised input
Parameterised queries do bind the value in a LIKE clause safely, but they do not escape wildcard characters within the value. If a user searches for 50%, the % is treated as a wildcard and matches everything with a price starting with 50. Usually that is a logic bug rather than a security hole, but in some schemas it can be used to enumerate data.
// Sequelize — wildcards in search term not escaped
const results = await Product.findAll({
where: {
name: { [Op.like]: `%${searchTerm}%` }
}
});
The value is bound (good), but % and _ inside searchTerm are still interpreted as SQL wildcard characters. Escape them before interpolating into the pattern:
function escapeLike(value: string): string {
return value.replace(/[%_\\]/g, '\\$&');
}
const results = await Product.findAll({
where: {
name: { [Op.like]: `%${escapeLike(searchTerm)}%` }
}
});
What a static analyser looks for
Detecting these patterns programmatically is more tractable than it sounds. The signals are specific:
- Template literals or string concatenation passed to methods known to accept raw SQL (
$queryRaw,query(),execute(),raw()) - String interpolation inside
LIKEpatterns, even when the outer call is parameterised orderBy,order, orsortcalls where the column name argument is derived from an external variable rather than a literal or an allowlist check- Type annotation gaps: a
stringfromreq.queryflowing directly to a query method without any narrowing
None of these require running the code. They are syntactic patterns visible in the AST. The hard part is reducing false positives: not every template literal in a database call is injection-prone, and not every dynamic orderBy is missing an allowlist. A good analyser builds a simple data-flow graph to check whether external input actually reaches the sink.
Safe rewrites in practice
The rule of thumb I apply in code review: if you can see user-controlled data between backticks in a database call, it needs a second look. Either the method supports tagged template parameterisation (use it), or the value is a column name (use an allowlist), or the value feeds a LIKE pattern (escape wildcards before binding).
None of this is novel. Parameterised queries have been the standard answer to SQL injection since the late 1990s. What is new is that LLMs make it easy to generate a lot of plausible-looking code quickly, and the injection patterns they introduce are subtle enough that they survive code review. A method call that takes a tagged template literal looks almost identical to one that takes a plain string. The difference is invisible until someone sends a malicious payload.
Seaworthy flags this pattern automatically.
This article was generated by AI and summarises publicly available sources.
Seaworthy scans your repo for the issues covered in articles like this one.
Security gaps, exposed secrets, and misconfigurations — caught before you deploy. Free to run, no account needed.