Prevention — Java
PreparedStatement, the MyBatis #{} vs ${} distinction, JPA bind parameters, and the identifier problem no placeholder solves.
The Rule
Use PreparedStatement with ? placeholders for every value. Never build SQL by string concatenation, and never pass concatenated SQL to Statement.
The two Java-specific traps are MyBatis's near-identical #{} and ${} syntaxes, and JPA's createQuery/createNativeQuery accepting concatenated strings. Both are covered below.
JDBC
// Statement + concatenation. The textbook vulnerability.
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(
"SELECT * FROM users WHERE name = '" + name + "'");
// String.format does not help — it is still concatenation.
String sql = String.format(
"SELECT * FROM users WHERE id = %s", id);MyBatis — #{} vs ${}
This is the single most common Java SQL injection in practice. #{value} becomes a bound parameter; ${value} is substituted into the SQL text. They look almost identical in a mapper file.
Grep every mapper for ${ and justify each hit. Legitimate uses exist for dynamic identifiers, but each needs an allowlist behind it — never a raw value.
MyBatis mappers
<!-- ${} substitutes raw text -->
<select id="find" resultType="User">
SELECT * FROM users WHERE name = '${name}'
</select>
<select id="list" resultType="User">
SELECT * FROM users ORDER BY ${sortColumn}
</select>JPA / Hibernate
// Concatenation into JPQL or native SQL is injectable either way.
List<User> u = em.createQuery(
"FROM User WHERE name = '" + name + "'", User.class)
.getResultList();
List<?> r = em.createNativeQuery(
"SELECT * FROM users WHERE name = '" + name + "'")
.getResultList();Dynamic Identifiers
No JDBC placeholder binds a table name, column name, or sort direction. ORDER BY ? is a syntax error. When these must be dynamic, map user input through an allowlist:
private static final Map<String,String> SORTABLE = Map.of(
"name", "name", "date", "created_at", "price", "price");
String column = SORTABLE.getOrDefault(userSort, "id");
String dir = "desc".equalsIgnoreCase(userDir) ? "DESC" : "ASC";
String sql = "SELECT * FROM products ORDER BY " + column + " " + dir;
The value that reaches the SQL is one you wrote, never the user's text. This is the only correct pattern for identifier contexts — see Injection Contexts.
Checklist
PreparedStatementwith?for every value; neverStatementwith concatenation.- MyBatis:
#{}for values; grep for${}and allowlist each one. - JPA:
setParameter, never concatenation, in both JPQL and native queries. - Identifiers: allowlist mapped to fixed strings.
- Set the JDBC connection to disallow multiple statements (
allowMultiQueries=falsefor MySQL, the default) to contain any residual injection. - Add a static-analysis rule (SpotBugs
SQL_INJECTION_*, Semgrep) to CI to catch regressions. - Run the application database account with least privilege — see Defense in Depth.
Related
Every ORM has an escape hatch to raw SQL, and every one of them is a footgun. Where the abstraction stops protecting you, and which method names to grep for.
Where your input lands in the query determines which payloads can possibly work. Get the context wrong and every payload fails for the wrong reason.
Mandatory FROM clauses, no LIMIT, no stacked queries, and uppercase identifiers — the engine where the most standard payloads fail for syntactic reasons.
Verbose errors, universal stacked-query support, and xp_cmdshell — the friendliest engine to attack and the one where escalation is most direct.
Parameterisation removes the bug; these layers limit the blast radius when a parameterisation is missed. Least privilege, allowlists, monitoring, and what each is actually worth.