Skip to content
CWE-89A03:2021 – Injection

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

JavaUserDao.javaVulnerable
// 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

XMLUserMapper.xmlVulnerable
<!-- ${} 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

JavaVulnerable
// 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

  • PreparedStatement with ? for every value; never Statement with 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=false for 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.