' OR '1'='1' Is Not a Cheat Code: How the Parser Decides What Is Structure and What Is Data
The whole vulnerability is one line of string concatenation:
$email = $_POST['email'];
$sql = "SELECT id, role FROM users WHERE email = '$email' AND active = 1";
$user = $pdo->query($sql)->fetch();
Send admin@shop.com' -- as the email: the quote closes your string early, -- comments out the rest of the line including the active check, and you are logged in as admin. Send ' OR '1'='1 and the clause becomes email = '' OR '1'='1', which is true for every row — the first of which is usually the oldest administrator account. The database decides what is structure and what is data by parsing the text you sent; if user input lives inside that text, the user is co-authoring the query. Quoting the value in your SQL ('$email') does not help, because the attacker supplies their own quotes.
Interviewers ask what an attacker can do with that power, because extraction is not limited to logging in:
| Technique | Payload shape (MySQL) | How you detect it |
|---|---|---|
| Tautology / auth bypass | ' OR '1'='1' -- |
someone authenticates without the right credentials |
| UNION column probing | ' UNION SELECT null, password FROM users -- after finding the column count with ORDER BY 3 |
foreign data rendered in your page |
| Error-based extraction | ' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT version()))) -- |
MySQL error text — and your data — echoed into the response |
| Boolean blind | ?id=1 AND (SELECT MID(password,1,1) FROM users LIMIT 1)='a' |
page differs between true and false: one character per request |
| Time-based blind | ?id=1 AND IF(MID(password,1,1)='a', SLEEP(5), 0) |
the response takes five seconds; works with zero visible output |
Time-based blind is the one that ends arguments: even with every error suppressed and every page identical, the attacker still reads your database one SLEEP() at a time. This is why OWASP groups XSS, SQL injection, and command injection under one entry — A03:2021: Injection — and why the fix is structural rather than cosmetic.
The structural fix is a prepared statement: submit the query shape first, let the server parse it, and deliver values afterwards as data.
$stmt = $pdo->prepare('SELECT id, role FROM users WHERE email = :email AND active = 1');
$stmt->execute([':email' => $_POST['email']]); // "x' OR '1'='1" is just a value
$user = $stmt->fetch();
By the time the value exists, the structure is already decided — no quote in the payload can close a string, add a clause, or comment anything out, including payloads nobody has invented yet. That property is the reason parameterization beats every input filter: a filter must recognize every bad pattern, and a prepared statement does not recognize anything, it removes the grammar from the channel. For binding types, emulated versus native prepares, and what still needs a whitelist after binding, see Prepared Statements and Binding.
Escaping output for HTML — Escaping Output to Prevent XSS — follows the same rule for a different parser. The rest of this topic covers the defenses attackers actually slip past, and what caps the damage when one query escapes your review.
Ten Techniques That Defeat Naive Defenses (and the One Counter That Beats All Ten)
Every defense that works by recognizing bad input has a surprise waiting, because the attacker writes the next pattern. These ten show up repeatedly in audits; each row names the defense people actually write, and why it fails.
| # | Technique and payload | The naive defense it beats | Why it still works |
|---|---|---|---|
| 1 | Tautology with comment: ' OR '1'='1' -- |
"the value sits inside quotes in our SQL" | the attacker supplies their own quotes; -- deletes the rest of the line |
| 2 | Obfuscated keywords: ' uNiOn/**/SeLeCt null,password-- |
preg_match('/union select/i', $input) |
case, inline /**/ comments, tabs, and %0b break the regex — a blocklist must enumerate the world |
| 3 | UNION column probing: ' UNION SELECT 1,2,password FROM users-- |
"users only influence a WHERE value, worst case wrong rows" | matching column count/types lets input append result rows, not just filter them |
| 4 | Wide-byte (GBK): %bf%27 OR 1=1-- |
addslashes() / mysql_real_escape_string() |
with a GBK connection charset, 0xbf plus the added 0x5c forms one legal character, freeing the quote |
| 5 | Numeric context: ?id=1 OR 1=1 |
"we escape quotes, and our id is inside quotes" | a numeric fragment has no quotes to escape — nothing for the defense to act on |
| 6 | Second-order: store admin' -- as a display name, hit WHERE username = '$stored' later |
"we sanitized the input at registration" | the value was clean when written and weaponized when a different query concatenated it |
| 7 | Parameter pollution: ?id=1&id=2, ?id[]=1 |
allowlist regex applied to $_GET['id'] |
PHP hands back the last value or an array; the check runs against a shape the developer never tested |
| 8 | Stacked statements: 1; DROP TABLE users-- |
"the app only ever runs SELECT" | if multi-statements are enabled (mysqli_multi_query, PDO::MYSQL_ATTR_MULTI_STATEMENTS), a semicolon appends DDL |
| 9 | LIKE wildcard abuse: search for 100% or _ |
"we bind every value, so we're safe" | binding is fine, but % and _ act as syntax inside the pattern and return rows nobody asked for |
| 10 | Dynamic identifier: ?sort=price;(DROP...) into ORDER BY $_GET['sort'] |
prepared statements themselves | no placeholder exists for a column name — identifiers are grammar, not data |
Read the table by column. Rows 1–8 are defeated by not letting user input into syntax at all: prepare() plus execute() handles them identically, including payloads that do not exist yet — that is the difference between a defense and a guess. Row 9 is not injection but a wrong-answer bug: escape the pattern metacharacters (%, _, \) in the *data* before binding it. Row 10 is the one that survives parameterization, because the SQL grammar has no hole where a value belongs in ORDER BY — the counter is a whitelist your code owns:
const SORTABLE = ['price' => 'price ASC', 'newest' => 'created_at DESC', 'name' => 'name ASC'];
$order = SORTABLE[$_GET['sort'] ?? 'newest'] ?? SORTABLE['newest']; // a key, never raw text
$pdo->prepare("SELECT * FROM products ORDER BY $order");
Two corollaries worth saying out loud in an interview. First, escaping libraries (addslashes, mysql_escape_string) are charset-dependent point-in-time tricks — row 4 is twenty years old and still works against hand-rolled setups; parameterization is negotiated once per connection and holds everywhere. Second, a WAF rule or input filter is a detection layer that reduces noise, not a security boundary: attackers route around blocked words, and the query you forgot to prepare is still open the moment the rule is tuned for performance.
The next chapter is about the cases where injection still gets through despite all of this — what the database account can touch, what your error page says, and how much of the blast radius you can delete in advance.
Cast the ID, Whitelist the Column, Least-Privilege the Account: Capping One Missed Query
Assume a query slipped through review. Three decisions you made months earlier determine whether that incident is a bug report or a breach.
1. Numeric fragments get cast, not quoted. Anything that is a number by contract should stop being a string before it can touch a query string:
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1],
]);
if ($id === false || $id === null) {
http_response_code(422);
exit('id must be a positive integer');
}
// "1 OR 1=1" never gets here; had it, (int)'1 OR 1=1' === 1 — the parse stops at the first non-digit
$stmt = $pdo->prepare('SELECT * FROM orders WHERE id = :id');
$stmt->execute([':id' => $id]);
Casting removes risk from numeric query fragments — WHERE id =, LIMIT, OFFSET, category_id = — because after the cast there is no input left, only an integer. The cast must happen at the boundary, before the value enters any variable that could later be interpolated, or you are relying on the next developer to remember. Validate-and-reject beats cast-and-hope: a 422 on garbage input tells you a probe is happening, while a silent (int) cast of abc → 0 quietly returns the wrong rows.
2. Least privilege caps what a successful injection can touch. The web application's database account should be the one that runs your queries and nothing else:
CREATE USER 'shop_app'@'10.0.%' IDENTIFIED BY '…';
GRANT SELECT, INSERT, UPDATE ON shop.* TO 'shop_app'@'10.0.%';
-- deliberately absent: DROP, ALTER, GRANT, FILE, SUPER, PROCESS
With that grant in place, a perfect UNION SELECT from information_schema may still read your application tables — that is the incident — but DROP TABLE, GRANT, LOAD_FILE(), and writes outside shop.* fail with command denied to user. Schema migrations run under a second account whose credentials exist only in CI, never in the web app's environment. This is the same reasoning as Escaping Output to Prevent XSS: assume one layer fails, and design the layer underneath so the failure is bounded.
3. Your error page must not be the attacker's oracle. Error-based extraction works because the database's message reached the browser:
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); // full detail, always
try {
$order = $repo->find($id);
} catch (PDOException $e) {
error_log($e->getMessage() . ' query=' . $e->getTraceAsString()); // detail goes to the log
http_response_code(500);
echo 'We could not complete that request.'; // nothing parseable to the client
}
Returning SQLSTATE[42000]: syntax error, unexpected 'FROM' to production hands over column names, table names, and version hints — the raw material for rows 2 and 3 of the previous chapter's table. Logs keep the full text with the failing input fingerprint, so you can spot the probing pattern (ORDER BY 7, repeated SLEEP) before it becomes a data pull; alerting on query-error spikes is cheap and catches blind attempts early.
Then the interview question that separates candidates: "You have a query where the user picks the sort column — prepared statement?" Answer plainly: no, binding cannot stand in for an identifier; you whitelist labels to columns, then prepare the rest. And the follow-up: "Is a WAF enough?" No — it is a filtering layer in front of an application that must still keep user data out of SQL structure.
Next, CSRF Tokens for Forms: an attacker who needs no injection at all, only your victim's logged-in browser.
Quiz
1. The login query is SELECT ... WHERE email = '$email' AND active = 1 and the attacker sends admin@shop.com' -- . What happens?
2. A defense runs preg_match('/union select/i', $input) before every query. Which payload defeats it while still performing a UNION-based extraction?
3. Why does (int) casting, or FILTER_VALIDATE_INT, neutralize ?id=1 OR 1=1 even though no escaping happens?
4. Your web app's MySQL account has only GRANT SELECT, INSERT, UPDATE ON shop.*. A UNION-based injection succeeds. What is the realistic outcome?
Flashcards
Question
What does a prepared statement change about a query, and why does that end SQL injection?
Click to reveal answer
Answer
Structure is parsed before any value exists: prepare() fixes the SQL text, execute() delivers values as data. A quote, OR tautology, UNION, or comment in a value can never re-enter the parser as syntax, so the only statement that runs is the one you wrote — including against payloads nobody has invented yet.
Question
Name three techniques that defeat common naive defenses and the counter for each.
Click to reveal answer
Answer
Wide-byte GBK (%bf%27) defeats addslashes — counter: parameterize; keyword obfuscation (uNiOn/**/SeLeCt) defeats regex blocklists — counter: parameterize; dynamic ORDER BY defeats prepared statements themselves — counter: whitelist labels to columns. LIKE wildcards (% _) need escaping inside the data; numeric fragments need int casting.
Question
Why cast or FILTER_VALIDATE_INT numeric input before it reaches a query?
Click to reveal answer
Answer
A numeric fragment has no quotes for an escaper to protect, so escaping is the wrong tool. The cast is a parser that stops at the first non-digit ('1 OR 1=1' → 1), leaving an integer with no SQL text — and validating at the boundary lets you reject garbage with a 422 instead of silently casting it to 0.
Question
What does a least-privilege database account change about a successful injection?
Click to reveal answer
Answer
Nothing about whether injection succeeds — but it caps the blast radius. With only SELECT/INSERT/UPDATE on the app schema, UNION reads stay inside that schema while DROP, GRANT, FILE, and writes elsewhere are refused by the server; migrations run under a separate CI-only account so DDL is never reachable from the web tier.
Question
Why must production responses hide SQL error text?
Click to reveal answer
Answer
Error-based extraction reads data straight out of messages like SQLSTATE[42000] ... EXTRACTVALUE(...). Keep ERRMODE_EXCEPTION for code, log the full message plus query fingerprint, and return a generic 500 — the log keeps the detail for you, the attacker gets nothing parseable.
Revision Notes
Key Takeaways
- 1. Concatenation makes the attacker a co-author of the query; prepare() fixes structure first so values can never act as syntax
- 2. Ten bypass techniques beat blocklists, escaping libraries, and naive quoting — parameterization answers rows 1-8 identically, and it does not need to recognize the payload
- 3. Identifiers are the gap binding cannot close: ORDER BY, column lists, and table names need a whitelist map from labels to real columns
- 4. Cast numeric fragments at the boundary with FILTER_VALIDATE_INT; validate-and-reject beats cast-and-hope because silent (int) of garbage returns wrong rows
- 5. Least privilege plus generic errors turns a worst-case injection into a bounded data incident instead of server takeover
Interview Tips
- • "Show me the safe way to accept user input in SQL" — prepare with a named placeholder and execute with an array, then immediately name what binding does NOT cover (identifiers, LIKE wildcards) to show you have been past the tutorial
- • "Your WAF blocks OR and UNION — is that enough?" — no: case, inline comments, whitespace encodings, and numeric contexts all route around keyword rules, and a WAF tunes for false positives; the boundary is the query builder, not the filter
- • "What would an attacker do with a blind injection where every error is hidden?" — boolean or time-based extraction: SLEEP(5) per character proves data leaves one bit at a time, which is why you also alert on repeated slow queries
- • "How do you limit the damage of an injection you haven't found yet?" — least-privilege grants (no DROP/GRANT/FILE), separate migration credentials, generic error pages, and input fingerprints in the log; frame it as designing for the failure you cannot rule out
Cheat Sheet
Defeating SQL Injection
The vulnerable shape (never ship it):
$sql = "SELECT * FROM users WHERE email = '$email'"; // user co-authors the query
$pdo->query($sql);
The structural fix:
$stmt = $pdo->prepare('SELECT id, role FROM users WHERE email = :email AND active = 1');
$stmt->execute([':email' => $_POST['email']]); // structure parsed first, data delivered after
prepare() fixes the SQL text; nothing at execute() time can close a string, add a clause, or comment anything out — including payloads that do not exist yet.
Attack techniques a blocklist cannot cover:
| Technique | Payload shape | Counter |
|---|---|---|
| Tautology + comment | ' OR '1'='1' -- |
parameterize |
| Keyword obfuscation | ' uNiOn/**/SeLeCt … (case, /**/, %0b) |
parameterize — never regex-blocklist |
| UNION probing | ' UNION SELECT null,password-- (count via ORDER BY n) |
parameterize |
| Wide-byte GBK | %bf%27 defeats addslashes() |
parameterize (escape functions are charset-dependent) |
| Numeric context | ?id=1 OR 1=1 — no quotes exist to escape |
cast with FILTER_VALIDATE_INT / bind PARAM_INT |
| Second-order | clean at INSERT, concatenated in a later query | parameterize at every use site |
| Parameter pollution | ?id=1&id=2, ?id[]=1 |
is_scalar() + filter_input, one typed read |
| Stacked statements | 1; DROP TABLE users-- |
keep multi-statements off and prepare |
| LIKE wildcards | 100%, _ return unrequested rows |
escape % _ \ inside the data |
| Dynamic identifiers | ORDER BY $_GET['sort'] |
whitelist keys → columns; binding cannot help |
Boundary cast (numeric fragments):
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
if ($id === false || $id === null) { http_response_code(422); exit('bad id'); }
Identifier whitelist:
const SORTABLE = ['price' => 'price ASC', 'newest' => 'created_at DESC'];
$order = SORTABLE[$_GET['sort'] ?? 'newest'] ?? SORTABLE['newest'];
Blast-radius controls:
- Least privilege:
GRANT SELECT, INSERT, UPDATE ON shop.* TO 'shop_app'@…— noDROP/GRANT/FILE; migrations run under a separate CI-only account. - Errors:
PDO::ATTR_ERRMODE => ERRMODE_EXCEPTIONin code,error_log()the detail, generic 500 to the client — error text in responses is the raw material for error-based extraction. - Detection: log failed queries with their input fingerprint; alert on
ORDER BY-count probing or repeatedSLEEP.
Framing: OWASP groups SQLi under A03:2021: Injection; a WAF or input filter is a noise-reduction layer, not a boundary.
Interview one-liner: "Can a prepared statement protect ORDER BY?" No — identifiers are grammar, not data; whitelist them, prepare everything else.