Skip to main content

Command Palette

Search for a command to run...

SQL Injection in Java: Secure Coding Patterns (Part 1)

Updated
12 min read
SQL Injection in Java: Secure Coding Patterns (Part 1)
M

20+ years in software development, now focused on application security. Writing hands-on guides on secure coding patterns, vulnerability analysis, and security architecture.

SQL injection attacks are a common issue in modern web applications. In OWASP Top 10 2025, injection errors are one of the most significant threats to web application security. SQL injection attacks have been cited as one of the most common injection errors in database-driven web applications. SQL injection attacks have long been well-documented in software libraries, yet they are common in production code.

This guide contextualizes SQL injection attacks as a current issue within the modern Java landscape and provides a clear illustration of methods for developing a secure database interface layer despite existing vulnerabilities.

Understanding SQL Injection: The Fundamental Flaw

SQL injection is a boundary error, in the sense that it occurs at the boundary of code and data. The problem arises when the input, which is considered to be untrusted and comes from the user or some other external source, is evaluated as part of the SQL statement instead of being considered strictly as data.

The canonical attack remains pertinent because it explains the underlying mechanism of the attack:

// VULNERABLE: String concatenation creates an injection vector
String sql = "SELECT * FROM users WHERE username = '" + username + "'";

This code treats the username variable as trustworthy, directly embedding it into the SQL command structure. The database receives no indication that this value should be constrained to a data context only.

When a legitimate user provides the input bob, the resulting query executes as intended:

SELECT * FROM users WHERE username = 'bob';

However, when an attacker provides the input bob' OR '1'='1, the query structure fundamentally changes:

SELECT * FROM users WHERE username = 'bob' OR '1'='1';

The condition '1' = '1' will be true for each and every row in the table. The attacker has not entered only data but rather inserted code into the query. The database cannot distinguish between the code that the developer intended to write in the query and the code that the attacker entered because both are passed in the form of a single pre-assembled string.

This is what each SQL injection attack follows, regardless of what technology stack a system uses.

The Core Solution: Parameterization

To overcome SQL injection, it is necessary to strictly maintain the separation between SQL command structure and data provided by the users. This is accomplished using parameterization.

The parameterized query provides two different parameters to the database in a sequence: first, a query template in which placeholders for values are included; second, values to be replaced in placeholders.

The database parses and assembles a query template in order to define a logical structure of a query. The execution plan is generated. The user data appears only in this compiled stage. The structure of a query does not change afterwards. The database views incoming data as literal values to fill in predetermined placeholders.

When the attacker tries to inject a payload like ' OR '1'='1', the database does not reparse the query. Rather, it looks for records that have the username field as' OR '1'='1'. This will definitely not be the case in any record, and hence this attack will be thwarted.

The effectiveness of this process relies on the temporal and logical gap between structure and data: structure first and then data. String concatenation defies this gap by providing both structure and data at once.

Securing JDBC

Raw JDBC provides the most direct database access and consequently the most obvious vulnerability surface. The java.sql.Statement interface combined with string concatenation represents the archetypal SQL injection anti-pattern.

The Vulnerability

import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import javax.sql.DataSource;

// VULNERABLE: Direct string concatenation with Statement
public User findUserByUsername(String username) throws SQLException {
    // The username variable is concatenated directly into the query string
    String sql = "SELECT * FROM users WHERE username = '" + username + "'";

    try (Connection conn = dataSource.getConnection();
         Statement stmt = conn.createStatement();
         ResultSet rs = stmt.executeQuery(sql)) {

        if (rs.next()) {
            return mapToUser(rs);
        }
    }
    return null;
}

An attacker providing the input '; DROP TABLE users; -- can cause immediate data loss. The database will execute the SELECT statement, then execute the DROP statement, and finally ignore the remainder of the original query due to the SQL comment operator --.

The Solution: PreparedStatement

The Raw JDBC must use the PreparedStatement interface for all queries with external input. The Statement interface should only be used for static SQL.

This ensures that the separation of concerns between the query structure and the query data is maintained at the JDBC driver level, and the logical structure of the query is not affected by the user input in a PreparedStatement. However, it is still possible to misuse it. If the SQL string to be passed to conn.prepareStatement() is built by the developers through string concatenation, the problem is merely shifted to a different level. The PreparedStatement will not be able to safeguard a query template that is already flawed.

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import javax.sql.DataSource;

// SAFE: PreparedStatement with parameterized query
public User findUserByUsername(String username) throws SQLException {
    String sql = "SELECT * FROM users WHERE username = ?";

    try (Connection conn = dataSource.getConnection();
         PreparedStatement ps = conn.prepareStatement(sql)) {

        ps.setString(1, username);

        try (ResultSet rs = ps.executeQuery()) {
            if (rs.next()) {
                return mapToUser(rs);
            }
        }
    }
    return null;
}

What's important to note in this case is that the query template SELECT * FROM users WHERE username = ? is a compile-time constant. The value that will be used to fill in the username placeholder will be bound at runtime in a type-safe method call.

With more than one parameter, the same principle holds:

import java.sql.Connection;
import java.sql.Date;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import javax.sql.DataSource;

// SAFE: Multiple parameters with PreparedStatement
public List<User> findUsersByCriteria(String status, String role, 
                                       LocalDate createdAfter) throws SQLException {
    String sql = "SELECT * FROM users WHERE status = ? AND role = ? AND created_date > ?";

    try (Connection conn = dataSource.getConnection();
         PreparedStatement ps = conn.prepareStatement(sql)) {

        ps.setString(1, status);
        ps.setString(2, role);
        ps.setDate(3, Date.valueOf(createdAfter));

        try (ResultSet rs = ps.executeQuery()) {
            List<User> users = new ArrayList<>();
            while (rs.next()) {
                users.add(mapToUser(rs));
            }
            return users;
        }
    }
}

Securing Hibernate and JPA

Frameworks for object-relational mapping, such as Hibernate and JPA, include escape hatches that allow SQL execution in cases where it is required. These escape hatches allow bypassing protection features in cases of misuse.

The Vulnerabilities

Native queries with concatenations of strings are the most straightforward:

import jakarta.persistence.EntityManager;
import jakarta.persistence.Query;
import java.util.List;

// VULNERABLE: Native query with string concatenation
public List<User> findUsersByStatus(String status) {
    String sql = "SELECT * FROM users WHERE status = '" + status + "'";

    Query q = em.createNativeQuery(sql, User.class);
    return q.getResultList();
}

JPQL is object-level, whereas regular SQL is table-level, but the concatenation of strings is the same vulnerability. The building of dynamic queries is another common scenario in which the developer creates the vulnerability of SQL injection.

import jakarta.persistence.EntityManager;
import jakarta.persistence.TypedQuery;
import java.util.List;

// VULNERABLE: Dynamic JPQL construction with concatenation
public List<User> findUsersByCriteria(String username, String userRole) {
    String jpql = "SELECT u FROM User u WHERE u.username = '" + username + "'";

    if (userRole != null && !userRole.isEmpty()) {
        jpql += " AND u.role = '" + userRole + "'";
    }

    TypedQuery<User> query = em.createQuery(jpql, User.class);
    return query.getResultList();
}

Both the username and userRole parameters represent injection vectors. An attacker can inject ' OR '1'='1 into the username field to bypass both the username check and the role-based access control.

The Solutions

The Query and TypedQuery interfaces provide setParameter() methods that handle parameterization at the ORM layer, automatically generating safe SQL. However, they can still be used unsafely. If the query string itself is constructed through concatenation before being passed to createQuery() or createNativeQuery(), the ORM cannot provide protection. The vulnerability exists at the string construction phase before the ORM receives the query.

JPQL with named parameters provides the cleanest approach:

import jakarta.persistence.EntityManager;
import jakarta.persistence.NoResultException;
import jakarta.persistence.TypedQuery;
import java.util.Optional;

// SAFE: JPQL with named parameters and proper exception handling
public Optional<User> findUserByUsername(String username) {
    String jpql = "SELECT u FROM User u WHERE u.username = :username";

    TypedQuery<User> query = em.createQuery(jpql, User.class);
    query.setParameter("username", username);

    try {
        return Optional.of(query.getSingleResult());
    } catch (NoResultException e) {
        return Optional.empty();
    }
}

Notice how Optional is used and how NoResultException is handled. The getSingleResult() method raises an exception when no result is found. However, this can be considered unexpected. Using Optional to wrap the result helps to improve this.

In the case of native queries, positional parameters can be used effectively:

import jakarta.persistence.EntityManager;
import jakarta.persistence.Query;
import java.util.List;

// SAFE: Native query with positional parameters
@SuppressWarnings("unchecked")
public List<User> findUsersByStatus(String status) {
    String sql = "SELECT * FROM users WHERE status = ?1";

    Query q = em.createNativeQuery(sql, User.class);
    q.setParameter(1, status);

    return q.getResultList();
}

Complex queries with multiple named parameters follow the same pattern:

import jakarta.persistence.EntityManager;
import jakarta.persistence.TypedQuery;
import java.time.LocalDate;
import java.util.List;

// SAFE: Complex query with multiple named parameters
public List<User> findUsersByCriteria(String status, String role, LocalDate createdAfter) {
    String jpql = """
        SELECT u FROM User u 
        WHERE u.status = :status 
        AND u.role = :role 
        AND u.createdDate > :createdAfter
        """;

    TypedQuery<User> query = em.createQuery(jpql, User.class);
    query.setParameter("status", status);
    query.setParameter("role", role);
    query.setParameter("createdAfter", createdAfter);

    return query.getResultList();
}

Securing Spring Data JPA

Spring Data JPA adds more abstraction levels that can make it unnecessary to write queries by hand. But this abstraction can be abused.

The Spring Data provides safe, correctly parameterized JPQL either through the name of the method or the @Query annotation, reducing the need to write the query by hand. There are, nonetheless, two ways in which the Spring Data can be used unsafely: through the use of @Query with string concatenation, especially with nativeQuery = true, or the building of Specification or Predicate objects with string concatenation of field names or values.

Query Derivation (Safest Approach)

import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;

// SAFE: Spring Data derives query from method name
public interface UserRepository extends JpaRepository<User, Long> {

    // Spring Data generates: SELECT u FROM User u WHERE u.username = :username
    Optional<User> findByUsername(String username);

    // Complex method names are also supported
    List<User> findByStatusAndRoleOrderByCreatedDateDesc(String status, String role);

    // Multiple conditions with various operators
    List<User> findByStatusAndCreatedDateAfter(String status, LocalDate date);

    // Pattern matching - CAUTION: see note below
    List<User> findByEmailContaining(String emailPart);
}

An important note on findByEmailContaining: this method is protected against SQL injection, but it does not escape the wildcards from the input parameters. A user may input % or _ to a search method, and these will act as wildcards. A validation or escape mechanism should be implemented when developing a search feature for the user.

@Query Annotation

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDate;
import java.util.List;

// SAFE: Explicit JPQL with named parameters
public interface UserRepository extends JpaRepository<User, Long> {

    @Query("SELECT u FROM User u WHERE u.status = :status AND u.role = :role")
    List<User> findUsersByStatusAndRole(@Param("status") String status, 
                                        @Param("role") String role);

    // Native queries follow the same pattern
    @Query(value = "SELECT * FROM users WHERE status = ?1 AND role = ?2", nativeQuery = true)
    List<User> findUsersByStatusAndRoleNative(String status, String role);

    // Complex queries with multiple parameters
    @Query("""
        SELECT u FROM User u 
        WHERE u.status = :status 
        AND u.createdDate BETWEEN :startDate AND :endDate 
        ORDER BY u.createdDate DESC
        """)
    List<User> findUsersInDateRange(@Param("status") String status, 
                                    @Param("startDate") LocalDate startDate,  
                                    @Param("endDate") LocalDate endDate);
}

Dynamic Queries with Specifications

When building queries with a variable number of predicates, string concatenation is never the solution. The JPA Criteria API provides a type-safe mechanism for dynamic query construction.

import jakarta.persistence.EntityManager;
import jakarta.persistence.TypedQuery;
import java.util.List;

// VULNERABLE: String-based dynamic query construction
// NEVER DO THIS
public List<User> searchUsers(String username, String status, String role) {
    StringBuilder jpql = new StringBuilder("SELECT u FROM User u WHERE 1=1");

    if (username != null) {
        jpql.append(" AND u.username = '").append(username).append("'");
    }
    if (status != null) {
        jpql.append(" AND u.status = '").append(status).append("'");
    }
    if (role != null) {
        jpql.append(" AND u.role = '").append(role).append("'");
    }

    return em.createQuery(jpql.toString(), User.class).getResultList();
}

The correct approach uses the Specification pattern. First, your repository must extend JpaSpecificationExecutor:

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;

public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
    // Query derivation methods here
}

Then create a specifications class:

import org.springframework.data.jpa.domain.Specification;

public final class UserSpecifications {

    private UserSpecifications() {
        // Utility class
    }

    public static Specification<User> hasUsername(String username) {
        return (root, query, builder) ->
            username == null ? null : builder.equal(root.get("username"), username);
    }

    public static Specification<User> hasStatus(String status) {
        return (root, query, builder) ->
            status == null ? null : builder.equal(root.get("status"), status);
    }

    public static Specification<User> hasRole(String role) {
        return (root, query, builder) ->
            role == null ? null : builder.equal(root.get("role"), role);
    }
}

Finally, use it in a service:

import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import java.util.List;

import static com.example.security.UserSpecifications.*;

@Service
public class UserSearchService {

    private final UserRepository userRepository;

    // Constructor injection - Spring 6 best practice
    public UserSearchService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public List<User> searchUsers(String username, String status, String role) {
        Specification<User> spec = Specification
            .where(hasUsername(username))
            .and(hasStatus(status))
            .and(hasRole(role));

        return userRepository.findAll(spec);
    }
}

For more complex scenarios, the Specification pattern handles intricate logic while maintaining type safety:

import jakarta.persistence.criteria.Predicate;
import org.springframework.data.jpa.domain.Specification;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

public final class UserSpecifications {

    private UserSpecifications() {
        // Utility class
    }

    public static Specification<User> searchWithComplexCriteria(
            String username,
            List<String> statuses,
            LocalDate createdAfter,
            String emailDomain) {

        return (root, query, builder) -> {
            List<Predicate> predicates = new ArrayList<>();

            if (username != null && !username.isBlank()) {
                predicates.add(builder.equal(root.get("username"), username));
            }

            if (statuses != null && !statuses.isEmpty()) {
                predicates.add(root.get("status").in(statuses));
            }

            if (createdAfter != null) {
                predicates.add(builder.greaterThan(root.get("createdDate"), createdAfter));
            }

            if (emailDomain != null && !emailDomain.isBlank()) {
                // Escape LIKE wildcards to prevent wildcard injection
                // Order matters: escape the escape char first, then wildcards
                String escapedDomain = emailDomain
                    .replace("\\", "\\\\")
                    .replace("%", "\\%")
                    .replace("_", "\\_");

                predicates.add(builder.like(
                    root.get("email"), 
                    "%" + escapedDomain,
                    '\\'
                ));
            }

            return builder.and(predicates.toArray(new Predicate[0]));
        };
    }
}

Please notice the escaping of the wildcard character in the example of the LIKE clause. Although CriteriaBuilder takes care of SQL parameterization, escaping of LIKE pattern characters is not done automatically. If a user-provided input contains % or _, these characters will be evaluated as wildcards. The order of escaping is crucial: first escape the escape character and then escape the wildcards.

The Criteria Builder builds the query as a graph of objects, which means all the user data is parameterized at the framework level.

Wrapping Up Part 1

All safe implementations share the same basic design. The query template should be defined with placeholders in a static template, while using type-safe API calls for binding input data, without ever concatenating input data into query strings.

The framework-specific guidelines are as follows. For JDBC, you can use PreparedStatement with ? positional arguments with setString() or setInt(). For JPA/Hibernate, you can use TypedQuery or Query with :name or ?1 arguments with setParameter(). For Spring Data, you can make use of query derivation or @Query with arguments. For Specifications, you can make use of CriteriaBuilder to build graphs of objects rather than strings.

When you analyze or write code for accessing a database, you should ask yourself: Are any user-supplied inputs affecting the logic structure for this query? If yes, then you have a SQL injection vulnerability. Otherwise, you have a secure query.

SQL Injection

Part 1 of 3

Secure coding patterns for JDBC, JPA, Spring Data, and MyBatis. Defense-in-depth approaches, automated detection via Semgrep, and security architect's perspective on threat modeling, compliance, and security program design.

Up next

SQL Injection in Java: Defense Strategies and Detection (Part 2)

Welcome to Part 2 of this SQL injection in Java blog series. In Part 1, we introduced SQL injection and learned about secure coding best practices for SQL injections in JDBC, JPA/Hibernate, and Spring Data JPA. The second part will discuss the securi...

More from this blog

S

SecurityDepth | Application Security for Developers & Security Engineers

15 posts

Practical application security insights for developers and security engineers. Hands-on guides covering web vulnerabilities, secure coding, and modern security practices.