CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection') - Java
Overview
SQL Injection occurs when an application builds a SQL query out of untrusted data rather than parameterizing it. An attacker who controls part of the query text can bypass authentication, read or modify data the query was never meant to reach, or run administrative operations.
Primary Defence: Use PreparedStatement with parameterized queries (JDBC), JPA/Hibernate named parameters, or query builder frameworks that automatically parameterize values.
Common Vulnerable Patterns
String Concatenation
String userId = request.getParameter("id");
String query = "SELECT * FROM users WHERE id = " + userId;
Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery(query); // VULNERABLE
Why this is vulnerable:
- String concatenation incorporates user input directly into SQL queries without escaping, allowing attackers to inject SQL code (e.g.,
1 OR 1=1,1; DROP TABLE users--) that bypasses authentication or executes unauthorized database operations.
String.format()
String username = request.getParameter("username");
String query = String.format("SELECT * FROM users WHERE username = '%s'", username);
stmt.executeQuery(query); // VULNERABLE
Why this is vulnerable:
- String formatting embeds user input directly into the SQL string.
- Quotes and SQL operators can break out of the intended literal.
- Attackers can inject additional predicates or statements.
Secure Patterns
PreparedStatement (JDBC)
String userId = request.getParameter("id");
String query = "SELECT * FROM users WHERE id = ?";
PreparedStatement pstmt = connection.prepareStatement(query);
pstmt.setString(1, userId); // Parameterized value
ResultSet rs = pstmt.executeQuery(); // SECURE - the value was bound as a parameter
Why this works:
PreparedStatementsends the query structure and parameter values separately to the database.- The database treats parameter values as pure data, not executable SQL, preventing injection regardless of special characters in the input.
Named Parameters (JPA/Hibernate)
String username = request.getParameter("username");
String jpql = "SELECT u FROM User u WHERE u.username = :username";
TypedQuery<User> query = entityManager.createQuery(jpql, User.class);
query.setParameter("username", username); // SECURE - bound as a named parameter
List<User> users = query.getResultList();
Why this works:
- JPA named parameters (
:username) are placeholders the persistence provider binds at execution. - The framework ensures the value is sent as a parameter, not concatenated into the JPQL/SQL.
Criteria API (Type-Safe)
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<User> cq = cb.createQuery(User.class);
Root<User> user = cq.from(User.class);
cq.select(user).where(cb.equal(user.get("username"), username)); // SECURE - the Criteria API parameterizes the comparison
List<User> users = entityManager.createQuery(cq).getResultList();
Why this works:
- The Criteria API uses a type-safe, object-oriented approach to query building.
- The
cb.equal()method automatically creates a parameterized condition, ensuring the username value is treated as data, not SQL code.
Framework-Specific Guidance
Spring Data JPA
// Repository interface - completely safe
public interface UserRepository extends JpaRepository<User, Long> {
// SECURE - method name query
User findByUsername(String username);
// SECURE - @Query with named parameters
@Query("SELECT u FROM User u WHERE u.email = :email")
User findByEmail(@Param("email") String email);
}
Why this works:
- Spring Data JPA translates method names like
findByUsernameinto parameterized queries automatically. - The
@Queryannotation with:emailcreates named parameters. - Both approaches ensure values are sent as parameters, not embedded in SQL.
MyBatis
<!-- SECURE - XML mapper with a parameterized query -->
<select id="getUserById" resultType="User">
SELECT * FROM users WHERE id = #{userId}
</select>
Why this works:
- MyBatis uses
#{userId}as a parameterized placeholder in XML mappers when the mapper method exposes that name, for example through@Param("userId"). - The framework automatically creates a PreparedStatement and binds the userId parameter.
- Avoid
${userId}for untrusted values;${}performs text substitution and can create SQL injection.
Data Type Handling
Numeric Parameters
String idParam = request.getParameter("id");
try {
int id = Integer.parseInt(idParam); // Validate type first
String query = "SELECT * FROM users WHERE id = ?";
PreparedStatement pstmt = connection.prepareStatement(query);
pstmt.setInt(1, id); // Type-safe
// ...
} catch (NumberFormatException e) {
// Handle invalid input
}
IN Clauses (Multiple Values)
// Vulnerable - don't build dynamic IN clause
String ids = request.getParameter("ids"); // "1,2,3"
String query = "SELECT * FROM users WHERE id IN (" + ids + ")"; // VULNERABLE
Recommended: Spring Data JPA
List<Integer> userIds = Arrays.asList(1, 2, 3, 4, 5);
// SECURE - Spring Data JPA handles IN clauses
List<User> users = userRepository.findByIdIn(userIds);
// SECURE - or with the @Query annotation
@Query("SELECT u FROM User u WHERE u.id IN :ids")
List<User> findByIds(@Param("ids") List<Integer> ids);
Why this works:
- Spring Data JPA recognizes collection-type parameters and automatically generates parameterized IN clauses.
- Each value in the list becomes a separate parameter with its own
?placeholder, maintaining injection protection for multiple values.
Recommended: JPA/Hibernate with named parameters
List<Integer> userIds = Arrays.asList(1, 2, 3, 4, 5);
// SECURE - JPA expands collections
String jpql = "SELECT u FROM User u WHERE u.id IN :userIds";
TypedQuery<User> query = entityManager.createQuery(jpql, User.class);
query.setParameter("userIds", userIds);
List<User> users = query.getResultList();
// Generated SQL: SELECT * FROM users WHERE id IN (?, ?, ?, ?, ?)
Why this works:
- JPA automatically expands collection parameters into multiple placeholders.
- When you pass a List to
setParameter(), JPA binds each element as its own parameter.
JDBC PreparedStatement, the manual equivalent
// Build placeholders dynamically, but set each parameter safely
List<Integer> ids = parseIds(request.getParameter("ids"));
String placeholders = String.join(",", Collections.nCopies(ids.size(), "?"));
String query = "SELECT * FROM users WHERE id IN (" + placeholders + ")";
PreparedStatement pstmt = connection.prepareStatement(query);
for (int i = 0; i < ids.size(); i++) {
pstmt.setInt(i + 1, ids.get(i));
}
ResultSet rs = pstmt.executeQuery(); // SECURE - each value is parameterized
Why this works:
- While the placeholder string is concatenated (e.g., "?, ?, ?"), the actual values are bound using
setInt(), making each one a proper parameter. - Only the query structure is dynamic, not the data, preventing injection.
Migration Considerations
- Identify all SQL construction: Search for
Statement,createQuery, string concatenation with SQL keywords - Replace with PreparedStatement:
connection.createStatement()becomesconnection.prepareStatement(query) - Parameterize inputs: Replace concatenated values with
?placeholders - Set parameters: Use
setString(),setInt(), etc. for each placeholder - Test: Verify functionality and try injection payloads
Database Permission Hardening (Defense in Depth)
Even with parameterized queries, restrict database permissions to limit SQL injection impact:
MySQL/MariaDB Permission Examples
Create restricted application user:
-- Create user with limited permissions
CREATE USER 'app_user'@'app_server_ip' IDENTIFIED BY 'strong_password';
-- Grant specific table access only
GRANT SELECT, INSERT, UPDATE ON myapp.users TO 'app_user'@'app_server_ip';
GRANT SELECT, INSERT, UPDATE ON myapp.orders TO 'app_user'@'app_server_ip';
GRANT SELECT ON myapp.products TO 'app_user'@'app_server_ip'; -- Read-only
-- Explicitly deny dangerous operations
REVOKE FILE, PROCESS, SUPER, SHUTDOWN, CREATE USER ON *.* FROM 'app_user'@'app_server_ip';
REVOKE DROP, CREATE, ALTER, TRUNCATE ON myapp.* FROM 'app_user'@'app_server_ip';
-- Flush privileges
FLUSH PRIVILEGES;
What this prevents in SQLi attacks:
'; DROP TABLE users; --- No DROP permission' UNION SELECT * FROM admin_users --- No access to admin tables'; LOAD DATA INFILE '/etc/passwd' --- No FILE permission'; EXEC xp_cmdshell 'rm -rf /' --- No system command execution
Still possible (use prepared statements!):
- Data exfiltration from permitted tables
- Unauthorized data modification within granted permissions
Key principle: Least privilege reduces blast radius but does NOT eliminate SQL injection risk. Always use prepared statements.
Java connection string with restricted user:
String url = "jdbc:mysql://localhost:3306/myapp";
String username = "app_user"; // Restricted user, not root!
String password = System.getenv("DB_PASSWORD");
Connection conn = DriverManager.getConnection(url, username, password);
Considerations
Placeholders do not work for identifiers. A prepared statement binds
values, so a table name, a column name, or the direction in an ORDER BY
cannot be parameterised - the driver would quote it as a string and the query
would fail. This is why a query that looks parameterised can still be
injectable: the values are bound and the identifier is concatenated.
Map identifiers through an allowlist, and prefer removing the need. Where a
caller must influence an identifier, treat their input as a key into a
server-side map of permitted names and reject anything absent from it, rather
than validating the input and using it directly. Better still, ask whether the
identifier needs to be caller-controlled at all - a sort parameter that accepts
created_at or total is usually a fixed set of queries wearing a dynamic
costume, and enumerating them removes the question entirely.
Where the framework can resolve the name for you, let it do that too. On the
JPA and Hibernate side an identifier does not have to reach the query text as a
string: JPA Criteria's root.get(name) and Hibernate's
org.hibernate.query.Order resolve a property name against the entity
metamodel, so anything that is not a mapped attribute raises
PathElementException rather than being parsed. That is Hibernate's exception
type; Jakarta Persistence specifies IllegalArgumentException for an unknown
attribute, and Hibernate's is a subclass of it, so catching
IllegalArgumentException holds whichever provider is underneath. That guarantee is narrower than
it first looks, which is why it supplements the allowlist instead of replacing
it: it proves the name is an attribute, not that this caller may sort by it,
and Order.asc(User.class, "password") will sort by the password column quite
happily - ordering alone is enough to extract it a row at a time.
CWE-564 covers the Hibernate case in full.
Common Pitfalls
- Using MyBatis
${}substitution for a value that "seems like" it needs raw text, such as aLIKEpattern or anORDER BYcolumn, instead of#{}.${}performs plain text substitution, not parameter binding, so any untrusted value passed through it is injectable even though#{}is used correctly elsewhere in the same mapper. - Building a Spring Data JPA
@Queryby concatenating a dynamic part of the query, such as"SELECT u FROM User u WHERE u." + field + " = :value". The value half of the query is genuinely parameterized, which can create false confidence, but the concatenated field name is still an injectable identifier. - Parameterizing a
PreparedStatement's values correctly with?while building the base query text by concatenating a table or schema name derived from a multi-tenant request parameter, without allowlist validation of that identifier. - Using
Statementinstead ofPreparedStatementfor a query considered "internal only" (an admin report, a scheduled job), then later reusing that same query with a parameter sourced from user-facing code without revisiting whether the trust assumption still holds.
Additional Resources
- CWE-89: SQL Injection
- Hibernate Query Language (HQL)
- Hibernate: QuerySpecification and Order - the dynamic-ordering API that takes a property name and resolves it against the entity metamodel, so an identifier never reaches the query text as a string. It removes the need for an allowlist to stop injection; it does not decide which attributes a caller may sort by, which is still an allowlist's job
- JDBC PreparedStatement Documentation
- Jakarta Persistence - the query language specification, current successor to the Java EE 7 JPA tutorial. Its Criteria API is the portable form of the same idea:
root.get(name)resolves against the metamodel and rejects anything that is not a mapped attribute - MyBatis Dynamic SQL
- OWASP SQL Injection Prevention Cheat Sheet
- Spring Data JPA Reference