Sql Injection And Why String Interpolation Is The Enemy
A single quote and two dashes can turn a login form into an open door. The whole trick lives in how the query string gets built, not in anything SQL itself does wrong.
The last post covered the patient version of an attack, watching responses for tells. SQL injection is the opposite: loud, fast, and it doesn't need a single leaked stack trace to work.
A Login Form, Broken With One Line
Picture a small job board with a login form. Under the hood, the server takes the email and password fields and builds a query like this.
const query = `SELECT * FROM users WHERE email = '${email}' AND password = '${password}'`;That looks harmless if the email and password are exactly what the field names promise. The problem is the server has no way to tell the difference between "this is data" and "this is part of my query." It just glues the string together and runs it.
Now say someone types neal@example.com into the email field, and instead of a real password, types this into the password field.
' OR 1=1--The resulting query, after the string gets built, looks like this.
SELECT * FROM users WHERE email = 'neal@example.com' AND password = '' OR 1=1--'Walk through it piece by piece and the trick stops looking clever and starts looking obvious.
- The opening quote after
password =closes the password string early OR 1=1adds a condition that is always true, no matter what--marks everything after it as a comment, so the trailing quote never matters
The database reads that as "find me a user where the email matches, or where one equals one." One equals one is always true, so the condition passes for every single row in the table. The very first result comes back, and whoever it belongs to, the attacker is now logged in as them.
ExpandRaw string interpolation letting a quote and a comment marker rewrite the query's logic, next to a parameterized version where the same input is just inert data.
The Actual Cause Isn't SQL
It's tempting to file this under "SQL is dangerous." That's backwards.
The cause is treating user input as executable text instead of as data. SQL injection is just the most famous member of a much bigger family: anywhere a raw string gets built by splicing in something a user typed, and that string then gets interpreted as code, the exact same shape of bug is possible. Command injection into Bash and remote code execution through eval both follow the identical pattern with a different target language.
Attackers Fingerprint Your Stack First
Getting the syntax right matters, because SQLite, MySQL, and Postgres do not all speak the exact same dialect, and a NoSQL database like Mongo needs a completely different kind of payload altogether. An attacker who doesn't know what's running underneath has to guess, so the more they can learn about your stack, the faster they can pick the right attack.
Two common ways a server gives that away for free.
- Leaked stack traces. An error page that prints the full exception, including library names and versions, hands over a map of exactly what's running.
- Framework fingerprint headers. Express sets
X-Powered-By: Expressby default. Anyone scanning responses now knows precisely what to target, including any framework-specific vulnerability that dropped last week.
The fix for both is the same: turn them off. Disable detailed error pages in production, and disable the header.
app.disable("x-powered-by");It's technically possible to set that header to something misleading instead of removing it, claiming to be a framework you're not. That's not really a defense, it's just inviting the wrong kind of curiosity. Removing the header entirely is the boring, correct move.
The Login Form Isn't the Only Target
It's easy to assume this bug only matters on a login form, since that's where the damage is most obvious. Any endpoint that builds a query from unescaped input is exposed, including ones that look completely unrelated to authentication.
Say that same job board has a search box for listings, hitting an endpoint like GET /api/listings?search=intern. Built the same unsafe way, that query might look like this.
const query = `SELECT * FROM listings WHERE title LIKE '%${search}%'`;A search box has nothing to do with logging in, so nobody thinks to guard it as carefully. But UNION SELECT lets an attacker append a second, unrelated query onto the first one, as long as the column counts line up.
' UNION SELECT email, password, NULL FROM users--The listings table and the users table have nothing to do with each other, and that's exactly the point. UNION SELECT doesn't care. It just needs a syntactically valid second query, and the search box hands it a way to run one. What comes back looks like search results, but the rows are actually usernames and passwords pulled from a completely different table.
That's the real lesson: the attack surface is every unescaped input, not just the one guarding the front door. A parameterized search box closes this exactly the same way a parameterized login form does.
The Real Fix: Stop Building Queries Out of Strings
Escaping quotes by hand is not the fix. It's the same trap as obfuscating a plain-text cookie value instead of fixing the trust model underneath it: a patch on the symptom, not the cause.
The actual fix is a parameterized query, also called a prepared statement. Instead of splicing values into the query text, you hand the database a template with placeholders, and the values separately.
db.get(
"SELECT * FROM users WHERE email = ? AND password = ?",
email,
password
);The database driver sends the query shape and the values as two separate pieces. A quote typed into the email field is just a character at that point, not a command. There's no string for it to break out of, because the values were never part of the query text in the first place.
This is what any SQL library or ORM worth using does under the hood already. Using one doesn't make code inherently safe, but it does mean the obvious mistakes have already been found by people other than you, the same reasoning covered in the signed-cookies post about trusting cookie-parser over hand-rolled signing. Reach for the library. Just don't assume it protects you from every architectural decision you make on top of it.
Logging Is the Cheap Defense Nobody Ships On Time
There's a habit worth breaking here: treating logging and alerting as a "fast follow" after launch.
It never actually gets followed up on. Something else always outranks it on the roadmap, and by the time anyone circles back, the app has been running blind for months.
Logging failed login attempts, unusual query patterns, and repeated requests hitting the same endpoint costs very little to add on day one. It buys you two things at once: a paper trail if an attack succeeds, and often a warning before it does. An account trying twenty variations of ' OR 1=1-- in a row is not subtle, if anyone's watching.
What This Sets Up
SQL injection targets a string built for a database. The next vulnerability targets a different kind of string: an entire object, blindly trusted from the request body.
The Essentials
' OR 1=1--works because it closes the string early, adds an always-true condition, and comments out the rest.- The root cause is treating user input as part of the query text, not anything specific to SQL as a language.
- Leaked stack traces and framework headers like
X-Powered-Byhand attackers a map of your stack. Turn both off. - Parameterized queries fix this by keeping values separate from the query template, so a quote is just a character.
- A well-maintained query library isn't inherently safe, but it does mean more people have already found the obvious bugs.
- Logging and alerting are cheap and belong on day one, not as a fast follow that never actually happens.
Next up: what happens when an update endpoint blindly trusts the entire request body, and why no library can save an architecture decision like that one.
Further Reading and Watching
- Running an SQL Injection Attack - Computerphile: a live demonstration of exactly how a broken login query gets bypassed.
- OWASP SQL Injection Prevention Cheat Sheet: the canonical reference for parameterized queries and other defenses against this exact class of bug.
Keep reading