Security · 4 min read · 802 words

SQL Injection in Plain English: User Input Rewrites Query

By Chris Clark · AppSec practitioner & AWS Solutions Architect

Disclosure: Some links in this article are affiliate links. We may earn a commission at no extra cost to you if you purchase through them.

SQL Injection in Plain English: When User Input Rewrites Your Query

SQL injection is the oldest bug on most AppSec lists, and it still turns up in code review. The idea is simple: your app builds a database query by gluing text together, and part of that text came from a user. If the user's text contains SQL, the database can't tell where your query ends and theirs begins.

MITRE files it as CWE-89. OWASP's Top 10:2025 groups it under A05:2025 Injection. Long names, same problem: data got treated as code.

What it looks like in a Java 8 app

Here's the classic version, straight from the OWASP SQL Injection Prevention Cheat Sheet:

String query = "SELECT account_balance FROM user_data WHERE user_name = "
             + request.getParameter("customerName");
Statement statement = connection.createStatement();
ResultSet results = statement.executeQuery(query);

Send tom' or '1'='1 instead of a name and the WHERE clause matches every row. On databases that allow stacked statements, '; DELETE FROM items; -- does worse.

Hibernate doesn't make it go away. HQL and JPQL (the object-flavored query languages Hibernate and JPA use) have the same bug when you build them with +. MITRE has a child entry for it, CWE-564:

session.createQuery("from Inventory where productID='" + userSuppliedParameter + "'");

The fix: placeholders, not glue

Parameterized queries (also called prepared statements) flip the order. You write the whole SQL first with ? or :name holes, then hand the values over separately. The database settles the shape of the query before it sees the value, so tom' or '1'='1 is just a weird username that matches nobody.

Plain JDBC:

PreparedStatement ps = connection.prepareStatement(
    "SELECT account_balance FROM user_data WHERE user_name = ?");
ps.setString(1, custname);

Spring's JdbcTemplate does the same with less ceremony:

int count = jdbcTemplate.queryForObject(
    "select count(*) from t_actor where first_name = ?", Integer.class, firstName);

On the JPA side, Spring Data JPA derived queries like findByEmailAddress(String email) are bound for you. @Query("select u from User u where u.emailAddress = ?1") binds the same way.

The trap: PreparedStatement isn't the fix, the ? is

connection.prepareStatement("SELECT * FROM users WHERE name = '" + name + "'");

That's a PreparedStatement, and it's still injectable. The class name doesn't matter. What matters is whether the user's value went into the SQL string or into a bind slot.

Where it hides during a strangler replacement

When you carve a section out of the Java 8 monolith and rebuild it in Spring Boot, the risky move is porting the old DAO's query-building code "as-is, so behavior stays identical."

A Spring Data @Query string has to be a compile-time constant (a Java annotation rule), so you can't glue request input into it directly. That's good. It also means the old concatenation tends to land right next to it: a custom repository method calling entityManager.createNativeQuery(sql + filter), or a JdbcTemplate call with a hand-built WHERE clause. It sits in a fresh Spring Boot service with constructor injection and unit tests. It reads like modern code. It isn't.

Review rule: every user value arrives as a method argument and lands on a ? or :name. Never as a piece of a string.

What GitLab SAST flags

GitLab's standard SAST for Java runs a Semgrep-based analyzer. The SQL rule (java_inject_rule-SqlInjection) is a taint rule. "Taint" means it follows data from where it enters to where it gets used. It tracks values through +, concat, StringBuilder.append, and String.format, then flags them when they reach sinks like Statement.executeQuery, JdbcTemplate.query, or EntityManager.createNativeQuery.

When the finding lands in Jira:

  • Go to the sink line. If the SQL argument was built with + from anything that isn't a constant, treat it as real.
  • Make the fix mechanical. Move the value to a placeholder and pass it as an argument. Don't "fix" it with escaping. OWASP marks escaping as strongly discouraged because it's database-specific and fragile.
  • Shrink the blast radius anyway. The app's database account shouldn't be DBA or owner. One missed query shouldn't be able to read or drop everything.
  • The short version

    SQL injection happens when user text becomes part of the SQL instead of a value handed to it. In Java and Spring Boot the fix is boring: PreparedStatement with ?, JdbcTemplate with trailing arguments, :name parameters in JPQL and @Query. Watch the strangler port, where old string-built queries move into new-looking Spring Boot code.


    Chris's take: I tag every SQL injection ticket by which side of the strangler it's on. Old-side findings in code already scheduled for deletion get a dated risk acceptance tied to the cutover, and new-side findings block the merge request. Otherwise the team burns a sprint parameterizing a DAO that won't exist next quarter while the new service quietly picks up the same pattern.

    Tags: sql-injection · database-security · application-security · appsec · vulnerability