SQL injection: break it live, then fix it
Run a real SQL injection against a real in-browser SQLite database, watch every row come back, then fix it the way Linkstash actually does.

You've written this exact line of code, or one close enough. A search box, a customer lookup, a "find by name" query built by gluing a variable into a string. It works in every test you tried. Then someone types a single quote and your database hands them the whole table.
This lesson runs that attack for real, against a real SQLite database sitting in your browser right now, no server involved. If you've read SELECT basics, you already know the query shape we're about to break.
A lookup that looks fine
Say Linkstash's team is adding a customer search feature (same database, same in-browser engine you've used all series). The obvious first draft builds the query by pasting the search term straight into the string:
function findCustomer(name) {
return db.exec(`SELECT id, name, city FROM customers WHERE name = '${name}'`);
}Call findCustomer("Maya") and you get exactly what you'd expect:
One row, Maya's row. Ship it, right? This is where almost every SQL injection starts: a query that's correct for every input you thought to try.
The same query, one different input
Now call findCustomer("' OR 1=1 --"). Watch what the template literal actually builds. name doesn't get treated as data anymore, it becomes part of the SQL itself:
SELECT id, name, city FROM customers WHERE name = '' OR 1=1 --'Walk through it slowly. The empty quotes close the string right after name = ''. Then OR 1=1 is a condition that's true for every row in the table, always. Then -- starts a SQL comment, which swallows the leftover quote that would otherwise break the syntax. Run it yourself:
Every customer. All eight rows, not just Maya's. The WHERE clause didn't fail, it did exactly what it was told: return every row where the name is empty OR one equals one, and one always equals one. Nobody broke the database. They just got it to agree with a true statement instead of the one you meant to ask.
This is not a toy example
Swap customers for a real users table with a passwordHash column and this is the same technique that's been behind account-database dumps for two decades. It's still in the OWASP Top 10 because "build SQL by pasting in a string" is still how people write their first query.
Why the fix isn't smarter string handling
The instinct is to block the dangerous characters: strip quotes, reject dashes, block the word OR. Don't. You'll miss a case (UNION, /*, encoded quotes, a hundred variants), and you'll break legitimate input like a customer named O'Brien. The actual fix is to stop building SQL out of strings at all.
A parameterized query sends the SQL structure and the data separately. The database gets WHERE name = ? as the fixed shape of the query, and the value arrives afterward as pure data, never parsed as SQL syntax. There's no string for an attacker to break out of, because the value was never part of the string.
You can see the effect even inside this literal-query playground. If a database treats your entire input as one opaque value (exactly what a bound parameter does), searching for a customer literally named ' OR 1=1 -- finds nobody, because nobody has that name:
Zero rows. The doubled quote ('''') is how you write a literal single quote inside a SQL string, so this query is now searching for a customer whose name is genuinely ' OR 1=1 --. That's the entire idea behind parameter binding: the driver does exactly this kind of correct escaping for you, every time, without you writing SQL string-manipulation code by hand.
The real fix, quoted from Linkstash
<SqlPlayground> only ever runs a literal query string, so it can't call a Node driver with a bound ? placeholder the way a real backend does. Here's what that actually looks like in the app this whole series attacks, listLinks from reference/linkstash/src/links.ts:
export function listLinks(db: Database, userId: number, opts: ListOptions = {}): Link[] {
const { limit = 20, offset = 0, q } = opts;
const where = q ? "WHERE userId = ? AND title LIKE ?" : "WHERE userId = ?";
const params: (string | number)[] = q ? [userId, `%${q}%`] : [userId];
return db
.prepare(`SELECT id, userId, url, title, createdAt FROM links ${where} ORDER BY id DESC LIMIT ? OFFSET ?`)
.all(...params, limit, offset) as Link[];
}Every value, userId, the search text, limit, offset, arrives through a ? placeholder and gets bound with .all(...params, limit, offset). None of it is pasted into the SQL string. Notice the search term still gets a template literal, `%${q}%` , and that's not a contradiction. That interpolation builds the value (adding wildcard % characters for a LIKE search), and the whole resulting value still travels through ?, never through the query text. Interpolating into a value is fine. Interpolating into the SQL structure is the entire bug.
The rule in one line
If a variable ever ends up inside the backtick string that holds your SQL, stop. Move it to a ? and pass it as a bound parameter instead.
What findCustomer should have looked like
import Database from "better-sqlite3";
function findCustomer(db: Database.Database, name: string) {
return db.prepare("SELECT id, name, city FROM customers WHERE name = ?").get(name);
}Same behavior for every legitimate name, including O'Brien, and ' OR 1=1 -- just fails to match anyone, exactly like it did in the playground above.
Quick check
Why does interpolating `%${q}%` into the LIKE value in listLinks stay safe, while interpolating `name` into the query string in findCustomer does not?
Try it yourself
Go back to the first playground on this page and try other payloads: x' UNION SELECT id, name, category FROM products --, or just nobody. Watch which ones break out of the string and which ones don't. Then notice that the parameterized version at the bottom never cares what you type, because it was never going to read it as SQL in the first place.
Next: XSS, where the injection target isn't a database, it's the page itself.

Written by
Rhythm Bhiwani
Engineer and relentless builder, happiest reverse-engineering hard problems until they click.
Enjoyed this?
Tap the heart to leave some love.
Be the first to react
Comments
Join the conversation.
Loading comments…


