Safeguard
Security Guides

Java SQL Injection Prevention: Parameterized Queries and Beyond

SQL injection is decades old and still breaching Java apps. Here's how to prevent it with prepared statements, JPA binding, and safe dynamic queries.

Marcus Chen
AppSec Engineer
5 min read

SQL injection has been a top web vulnerability since before many Java developers started their careers, and it stubbornly refuses to disappear. It still ranks under OWASP's A03:2021 "Injection" category, and it still shows up in fresh CVEs every year — usually not in an ORM's core, but in some helper method a team wrote to build a query "just this once" with string concatenation. The fix is well understood and cheap. The reason SQLi persists is that Java makes the wrong way just as easy to write as the right way, and it compiles without complaint. This guide is about making the safe pattern the default one.

Why does SQL injection still happen in Java?

It happens because string concatenation is the path of least resistance. When a developer needs a query that depends on user input, the fastest thing to type is "... WHERE x = '" + input + "'", and it works in testing because testers don't send SQL metacharacters. The vulnerability is invisible until an attacker sends ' OR '1'='1 or a stacked query. Prevention isn't about knowing an obscure API — it's about never letting user input become part of the SQL text, only its parameters.

Use PreparedStatement for JDBC

With PreparedStatement, the SQL structure is sent to the database first and parameters are bound separately, so input can never change the query's meaning:

// Vulnerable
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(
    "SELECT * FROM accounts WHERE owner = '" + user + "'");

// Safe
String sql = "SELECT * FROM accounts WHERE owner = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
    ps.setString(1, user);
    ResultSet rs = ps.executeQuery();
}

The ? placeholder is not string interpolation — the driver keeps the value strictly as data. This single pattern eliminates the overwhelming majority of SQLi.

Bind parameters in JPA and Hibernate too

An ORM does not automatically protect you. JPQL and native queries built with concatenation are just as injectable as raw JDBC. Use named parameters:

// Vulnerable JPQL — input concatenated into the query string
em.createQuery("SELECT u FROM User u WHERE u.email = '" + email + "'");

// Safe — bound named parameter
TypedQuery<User> q = em.createQuery(
    "SELECT u FROM User u WHERE u.email = :email", User.class);
q.setParameter("email", email);

Spring Data JPA's derived query methods and @Query with :param binding are safe by default; the danger returns the moment someone drops to a native query with a concatenated string.

The hard case: dynamic queries and sorting

Parameters cover values, but they cannot parameterize identifiers — you can't bind a column name, table name, or sort direction. This is where injection sneaks back in through "dynamic" search and sort features. The rule: identifiers must come from a server-side allow-list, never from raw input.

// User asks to sort by a column — validate against a fixed set
private static final Set<String> SORTABLE =
    Set.of("created_at", "amount", "status");

String sortColumn = SORTABLE.contains(requested) ? requested : "created_at";
String dir = "DESC".equalsIgnoreCase(requestedDir) ? "DESC" : "ASC";

String sql = "SELECT * FROM orders ORDER BY " + sortColumn + " " + dir;
// sortColumn/dir can only be known-safe values, so concatenation is now safe

For complex, conditional queries, prefer the JPA Criteria API or a query builder that binds parameters for you, rather than assembling strings by hand.

Defense in depth

Parameterization is the primary control, but layer additional protections:

LayerControlValue
QueryPrepared statements / bound paramsEliminates classic SQLi
IdentifiersServer-side allow-lists for columns/sortCovers what params can't
DatabaseLeast-privilege DB accountsLimits blast radius
DatabaseDisable stacked queries where possibleBlocks piggybacked statements
PipelineSAST + dependency scanningCatches regressions early

Grant the application's database user only the permissions it needs — a read API should not connect with an account that can DROP TABLE. If the primary defense ever fails, least privilege decides whether the incident is a data read or a total loss.

Common myths that reintroduce SQLi

A few persistent misconceptions put "protected" code back at risk. First, stored procedures are not automatically safe — a procedure that builds and executes dynamic SQL from its parameters is just as injectable as inline concatenation. Second, input escaping is not equivalent to parameterization; hand-rolled escaping routines miss edge cases across database dialects and character encodings, which is exactly why bound parameters exist. Third, an ORM does not immunize you — the moment someone writes a native query with a concatenated string, the abstraction is bypassed. Finally, client-side validation is not a security control; anything enforced only in the browser can be skipped with a direct API call. Treat every one of these as a review red flag when it appears in a pull request, because each represents a place where a developer believed they were safe while quietly reopening the oldest vulnerability on the web.

How Safeguard helps

Preventing SQL injection in your own code is a discipline problem, but a large share of Java data-access risk actually lives in the libraries underneath you — JDBC drivers, ORMs, connection pools, and query-builder utilities that periodically ship their own injection CVEs. Safeguard's software composition analysis tracks those data-layer dependencies across your full transitive tree and flags known injection and data-handling vulnerabilities as they're disclosed, while the reachability engine tells you whether the vulnerable code path is actually reachable from your queries. For the injection risk in code you write, dynamic application security testing exercises your running endpoints with injection payloads to confirm whether a sink is genuinely exploitable rather than just theoretically present, and auto-fix pull requests handle the dependency upgrades. Teams moving off scanner-only incumbents often review the Safeguard vs Snyk comparison to see the combined SCA-plus-DAST coverage.

Start free at app.safeguard.sh/register, and read the scanning setup guide at docs.safeguard.sh.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.