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:
+ from anything that isn't a constant, treat it as real.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.