How I Spot and Fix SQL Injection

August 9, 2026 Melalew Mengistu 18 min read

I first bumped into SQL Injection during a Capture The Flag challenge on TryHackMe. I watched other players fire up SQLMap, paste in a URL, and wait for the tool to spit out database tables like it was reading a phone book. At the time, I didn't care what was happening underneath. I just filed SQLMap away in my head as a magic wand that could hack any database on command.

That mindset caught up with me later when I realized SQL Injection wasn't a tool trick. It was an entire vulnerability class sitting on the OWASP Top 10. I decided to actually understand it instead of just running someone else's script. I searched YouTube and landed on a video by NetworkChuck called SQL Injections are scary!! That's where everything clicked. I finally understood that SQL Injection is basically manipulating the database into believing you. By breaking out of expected strings using simple quotes or comment markers, an attacker injects raw SQL logic so the database runs their commands instead of what the developer intended.

SQL injection attack diagram showing malicious query injection
Unparameterized queries remain one of the most critical vulnerabilities in web apps.
What This Guide Actually Covers

This isn't a "use prepared statements" one-liner. I'm walking through exactly how an injection string breaks a query, why your mobile app is just as exposed as your website, and the concrete patterns I use to lock things down: prepared statements, which separate query logic from user data so the database never confuses the two; safe ORMs, which are tools that map database tables to code objects and use parameterized queries under the hood; strict input validation, which means checking every incoming value against an allow-list of expected formats before it touches the database; least privilege, which is the practice of giving a database account only the minimum permissions it actually needs; and hiding error messages, because verbose database errors hand attackers a roadmap to your schema.

How the Break-In Actually Works

SQL Injection happens when an application takes user input and drops it directly into a SQL query string without sanitization or separation. The database receives one long string that mixes the developer's logic with the attacker's logic, and it executes the whole thing because it has no way to tell where the query ends and the payload begins.

The Classic Login Bypass

Imagine a login query built like this on the server:

PHP: Vulnerable Authentication Query
$query = "SELECT * FROM users WHERE username = '$username' AND password = '$password'";

If an attacker types ' OR '1'='1' -- into the username field, the resulting query becomes:

SQL: Injected Query
SELECT * FROM users WHERE username = '' OR '1'='1' -- ' AND password = 'anything'

The single quote closes the original string. OR '1'='1' adds a condition that is always true. The double-dash comments out the rest of the query. The database returns every user in the table, and if the application checks whether any row came back, the attacker is suddenly logged in as the first user—often an administrator. A schema is the structure that defines your tables, columns, relationships, and data types inside the database.

The payload doesn't have to be this exact string. Variations use UNION to pull data from other tables, stacked queries to run DELETE or DROP statements, and boolean-based blind injection to extract data one bit at a time when error messages are hidden. A stacked query is when an attacker appends an entirely new SQL statement after the original one using a semicolon separator.

Mobile Apps Are Not Immune

Just like when I studied XSS, I asked myself whether this was just a website problem. The answer is the same: absolutely not. Mobile apps communicate with backend databases through APIs, which are endpoints that accept requests and return data, usually in JSON format. If the backend builds queries by concatenating strings from JSON request bodies, the injection surface is identical. In hybrid apps using WebViews, if the local database or a remote API endpoint is vulnerable, the attack path is the same. The client doesn't matter. The database query does.

How I Prevent It Now

None of this requires expensive tooling. It requires stopping the habit of concatenating user input into SQL strings.

Prepared Statements Are Non-Negotiable

This is the single best defense. A prepared statement sends the query structure to the database first, before any data is involved. The database compiles the query and leaves placeholders for the actual values. When the application later binds the user input to those placeholders, the database treats the input strictly as data, never as executable code.

PHP: Secure PDO Prepared Statement
$stmt = $pdo->prepare(
  'SELECT * FROM users WHERE username = :username AND password = :password'
);
$stmt->execute([
  'username' => $username,
  'password' => $password
]);
$user = $stmt->fetch();

Even if an attacker sends the exact same ' OR '1'='1' -- payload, the database sees it as a literal string value to match against the username column. The OR '1'='1' logic never gets compiled.

Use Safe ORMs, But Don't Trust Them Blindly

Object-Relational Mappers like Prisma, TypeORM, and Entity Framework use parameterized queries under the hood, which makes accidental injection much harder. But "harder" isn't "impossible." If you use raw query methods like queryRaw or executeRaw and interpolate strings into them, you have bypassed the ORM's protection entirely. I treat any raw SQL method in an ORM as a review flag, just like dangerouslySetInnerHTML in React.

ORMs protect you when you stay inside their abstraction. The moment you write raw SQL and splice variables into it, you are back to manual string concatenation and all the risk that comes with it.

Validate Input and Lock Down the Database

Prepared statements handle the query boundary, but input validation is your first gate. I validate every incoming value against an allow-list of expected types, lengths, and formats before it ever touches the database layer. Rejecting malicious input early reduces the attack surface and catches logic errors before they become security issues.

I also apply the principle of least privilege to database accounts. The application user should only have the permissions it needs. If a web app only reads and writes user records, it does not get DROP, ALTER, or GRANT privileges. If injection somehow happens against a read-only account, the damage is contained.

Hide Database Error Messages

Detailed database errors are a roadmap for attackers. A verbose error that says "Unknown column 'foo' in field list" or reveals the database engine version gives an attacker confirmation that they are on the right track. In production, I suppress raw database errors from ever reaching the client. I log them securely on the server and return a generic message to the frontend.

Node.js: Generic Error Response
app.use((err, req, res, next) => {
  console.error(err); // Log securely server-side
  res.status(500).json({ error: 'Something went wrong.' });
});

Defense in depth means overlapping protections. Prepared statements are your primary wall, input validation is the gate, least privilege is the moat, and generic error messages are the blackout curtains. You want all of them.

Production Checklist

SQL Injection prevention isn't a one-time patch. These are the four rules I hold every codebase to before I call it production-ready:

  • Never concatenate user input into SQL strings; use prepared statements or parameterized queries everywhere
  • Treat raw query methods in ORMs as review flags and avoid string interpolation inside them
  • Run application database accounts with the minimum required privileges; never use root or admin credentials
  • Suppress detailed database errors in production; log them server-side and return generic responses to clients

None of this makes an app unhackable, and I don't treat it that way. SQL Injection was just another lock I learned to check. XSS, CSRF, broken access control, and insecure deserialization are all still sitting on the same list. Security is a process I keep working through, not a milestone I hit once and move past.

Understanding Blind SQL Injection

Once I understood basic SQLi, I ran into the next problem: what happens when the application doesn't show database errors at all? No error messages, no leaked data in the response. The page just returns "Login failed" or a blank result. This is where blind SQL injection comes in, and it's far more common in well-hardened apps than the verbose error version.

There are two main types. Boolean-based blind injection works by asking the database true/false questions. If the page behaves differently depending on whether a condition is true or false, you can extract data one bit at a time. For example:

SQL: Boolean-Based Blind Extraction
-- Does the first character of the admin password start with 'a'?
' AND SUBSTRING((SELECT password FROM users WHERE role='admin' LIMIT 1),1,1)='a' --

-- Page loads normally = true, page breaks or returns empty = false
-- Repeat for every character position until the full password is extracted

Time-based blind injection is the same concept but uses database delays instead of page behavior. Even if the page renders identically regardless of condition, the response time reveals the truth. MySQL's SLEEP(), PostgreSQL's pg_sleep(), and SQL Server's WAITFOR DELAY all let an attacker extract data by measuring how long the response takes.

SQL: Time-Based Blind Extraction (MySQL)
-- If the first char of admin password is 'a', sleep 5 seconds
' AND IF(SUBSTRING((SELECT password FROM users LIMIT 1),1,1)='a', SLEEP(5), 0) --

-- Response takes ~5 seconds = true, response is instant = false

Key insight: Blind injection is slower but just as dangerous. Automated tools like sqlmap can extract an entire database using only time delays — it just takes longer. Hiding errors does not prevent extraction; it only makes it noisier.

Union-Based Attacks and Schema Extraction

When errors are visible and the query returns a result set displayed on the page, UNION-based injection is the fastest way to pull data. The UNION SELECT statement appends a second query to the original one, and if the column count and data types match, the database merges both result sets into one response. The attacker sees their injected data right in the page.

The first thing an attacker does is figure out the column count using ORDER BY clauses — incrementing the column number until the query breaks. Then they match column types and start querying information_schema, which is a read-only meta-database every SQL database exposes that describes every table, column, and constraint in the instance.

SQL: Union Attack — Schema Extraction
-- Step 1: Find column count (try increasing ORDER BY until it breaks)
' ORDER BY 3 --

-- Step 2: Identify which columns reflect data back
' UNION SELECT NULL, NULL, NULL --

-- Step 3: Extract table names from information_schema
' UNION SELECT table_name, NULL, NULL
  FROM information_schema.tables
  WHERE table_schema = database() --

-- Step 4: Extract column names from a specific table
' UNION SELECT column_name, NULL, NULL
  FROM information_schema.columns
  WHERE table_name = 'users' --

-- Step 5: Dump credentials
' UNION SELECT username, password, NULL FROM users --

This progression from column-count discovery to credential dump takes minutes with a cooperative vulnerable endpoint. Everything an attacker needs to know about your database structure is available through information_schema — table names, column names, data types, relationships. That's why schema exposure through verbose errors is so dangerous: it skips the discovery phase entirely.

UNION attacks only work when the injected query's column count matches the original. But attackers have automated tools that try every column count automatically. Don't count on the match requirement to slow anyone down.

Testing Your Own Applications

I test my own apps for SQLi before anyone else does. The safest way to do this is in a controlled, isolated environment — a local clone with a test database, never against production. The goal is to confirm the vulnerability exists (or doesn't) without affecting real data.

Manual Testing with Single Quotes

The simplest first step is inserting a single quote into every input field, URL parameter, and HTTP header that touches the database. If the application returns a database error, throws a 500, or behaves differently than it does with valid input, that's a red flag worth investigating. Follow up with AND 1=1 (should behave normally) vs AND 1=2 (should return empty results). If those two inputs produce different results, you have a boolean-injectable parameter.

Using sqlmap Safely

sqlmap is the standard automated SQLi testing tool. I always run it in --level 1 --risk 1 mode first on a test environment, never against production. The --level parameter controls how many payloads are tested; higher levels include HTTP cookies and User-Agent headers. The --risk parameter controls payload aggression; risk 3 includes time-delay payloads that can stress a server.

Shell: Basic sqlmap Scan
# Test a specific URL parameter
sqlmap -u "http://localhost:3000/search?q=test" --level=1 --risk=1

# Test a POST form
sqlmap -u "http://localhost:3000/login" --data="username=test&password=test" --level=1

# If vulnerable, enumerate databases (confirm with --dbs)
sqlmap -u "http://localhost:3000/search?q=test" --dbs

Always get written permission before running sqlmap against any system you don't personally own. Even against your own staging server, document that the test was authorized. Running sqlmap against a production system without permission is illegal in most jurisdictions regardless of intent.

Real-World Impact Stories

SQL injection isn't a theoretical risk. It's been at the center of some of the largest data breaches in history. Understanding what actually happens after a successful injection helps calibrate the severity — this isn't "someone sees an error message." It's full compromise.

Data Theft at Scale

The most common outcome is mass credential extraction. An attacker identifies a vulnerable parameter, runs a UNION or blind extraction attack, and dumps the entire users table — usernames, email addresses, password hashes, and whatever else is stored there. If passwords are stored with weak hashing (MD5, SHA1, or worse, plaintext), they're cracked offline within hours. Those credentials are then used in credential stuffing attacks against every other service the user has an account on.

Privilege Escalation and Server Takeover

On misconfigured databases, SQLi doesn't stop at data theft. MySQL's INTO OUTFILE can write arbitrary files to the server's filesystem if the database process has write permission. SQL Server's xp_cmdshell stored procedure executes operating system commands directly. An attacker who identifies these features enabled gets a shell on the database server — and from there, the rest of the network is often one lateral movement away.

Key insight: The 2008 Heartland Payment Systems breach — 130 million card numbers stolen — started with SQL injection. The 2012 LinkedIn breach that exposed 117 million password hashes had SQLi as a contributing vector. Prepared statements would have prevented both at the entry point.

SQLi in Different Databases

SQL injection syntax isn't completely universal. Different database engines have different functions, comment syntax, and capabilities that affect both how payloads are constructed and what an attacker can do after gaining access. Knowing the differences matters when you're testing or hardening.

MySQL

The most common web database. Uses -- or # for comments. Supports SLEEP() for time-based attacks, INTO OUTFILE for file writes, and LOAD_FILE() for reading files. The information_schema database is the go-to for schema enumeration. Concatenation uses CONCAT() or the pipe operator doesn't work — use CONCAT(a, b).

PostgreSQL

Used widely in production at scale. Uses -- for comments. Time-based attacks use pg_sleep(). PostgreSQL supports COPY TO/FROM for file access, and the pg_read_file() function can read server files if the user has superuser privileges. PostgreSQL also supports the $$ dollar-quoting syntax which can bypass some naive quote-based filters.

SQL: Database-Specific Differences
-- MySQL: comment styles
SELECT * FROM users WHERE id=1 -- comment
SELECT * FROM users WHERE id=1 # comment

-- PostgreSQL: comment and string concatenation
SELECT * FROM users WHERE id=1 -- comment
SELECT 'a'||'b'  -- concatenation

-- SQLite: comment and limited functions
SELECT * FROM users WHERE id=1 -- comment
-- No SLEEP(), but randomblob() can cause delays

-- MSSQL: comment and command execution
SELECT * FROM users WHERE id=1 --
EXEC xp_cmdshell('whoami')  -- if enabled

SQLite

Common in mobile apps, Electron apps, and lightweight backends. It doesn't support multi-user access controls or stored procedures, but it's still injectable. SQLite's sqlite_master table replaces information_schema for schema enumeration. Time-based attacks use CPU-intensive queries like randomblob() calls inside loops. Because SQLite files are often local to the application binary, a successful read of the database file is equivalent to dumping the entire dataset.

Melalew Mengistu

Melalew Mengistu

Web engineer and web security specialist. Helps teams build and ship secure applications.