If you've built a login form, a search bar, or anything that takes user input and touches a database, you've built something that can be SQL-injected — unless you've deliberately made sure it can't be. This post walks through the mechanics of the attack and, more importantly, the exact code patterns that close it off for good, in both Flask/SQLite3 and Node/Express — the stacks most of us are shipping with day to day.

A note on how to use this post responsibly: everything here is meant to be tested against your own local environment — a database you own, or a deliberately vulnerable practice app like OWASP Juice Shop. Never run these techniques against a system you don't have explicit permission to test.


What SQL Injection Actually Is

SQL injection happens when user-supplied input gets concatenated directly into a SQL query string, instead of being treated strictly as data. The database can't tell the difference between "the text the user typed" and "part of the command it should execute" — so if the input contains SQL syntax, the database just... runs it.

Here's the classic, textbook example. Imagine a login query built like this (don't do this):

# VULNERABLE! never do this
query = f"SELECT * FROM users WHERE email = '{email}' AND password = '{password}'"
cursor.execute(query)

If someone enters this as the email field:

' OR '1'='1

The final query becomes:

SELECT * FROM users WHERE email = '' OR '1'='1' AND password = ''

Since '1'='1' is always true, the query can return every user row — including the admin account — without knowing a single real password. This exact pattern is what the Juice Shop "Login Admin" challenge is built to teach you to find and fix.


Why It's Still Everywhere in 2026

SQL injection has been on the OWASP Top 10 list since it started, and it hasn't disappeared for a simple reason: string concatenation into a query feels like the "obvious" way to write it, especially when you're moving fast. It works fine in every manual test you run yourself. It only breaks — catastrophically — the moment someone deliberately feeds it something you didn't expect.


The Actual Fix: Parameterized Queries

The fix isn't "sanitize the input harder." The fix is to stop building queries out of strings at all. Every mainstream database driver supports parameterized queries (also called prepared statements), where your input is passed separately from the SQL command, so the database always treats it as data — never as code.

Flask + SQLite3 (raw, no ORM)

# SAFE — parameterized query
def check_login(email, password):
    cursor.execute(
        "SELECT * FROM users WHERE email = ? AND password = ?",
        (email, password)
    )
    return cursor.fetchone()

The ? placeholders tell SQLite3: "this is data, not code." No matter what the user types — quotes, semicolons, OR 1=1 — it's treated as a literal string to match against, never as SQL syntax.

Node.js + Express (raw SQL, e.g. with better-sqlite3 or sqlite3)

// SAFE — parameterized query
function checkLogin(email, password) {
  const stmt = db.prepare(
    "SELECT * FROM users WHERE email = ? AND password = ?"
  );
  return stmt.get(email, password);
}

Same principle, same syntax pattern. This is the single most important habit to build: never use f-strings, .format(), %, or template literals to build a query with user input. If you find yourself typing f"...{variable}..." next to the word SELECT, stop and swap it for a placeholder.


Beyond the Query: Defense in Depth

Parameterized queries stop the core attack, but a properly hardened app layers a few more habits on top:

1. Least-privilege database accounts.
Your app's database user should only have the permissions it actually needs. A public-facing web app almost never needs DROP TABLE or ALTER permissions on its own connection — if an injection does slip through somewhere, a restricted account limits the blast radius.

2. Input validation at the edge.
Validate shape and type before the data ever reaches your query layer — an email field should look like an email, a numeric ID should parse as an integer. This isn't your primary defense (parameterization is), but it catches obviously malformed input early and gives better error messages.

3. Escaping for dynamic identifiers.
Occasionally you need to build a query where the table or column name itself is dynamic (parameterization only covers values, not identifiers). In these rare cases, use an explicit allow-list of valid table/column names — never pass raw user input into an identifier position, even indirectly.

4. Generic error messages.
Don't return raw database error messages to the client. sqlite3.OperationalError: near "OR": syntax error tells an attacker your queries are vulnerable and gives away your schema. Catch the exception, log it server-side, and return a generic "something went wrong" to the user.

5. Rate limiting on auth endpoints.
This doesn't stop injection directly, but it slows down anyone trying to brute-force their way through payloads on a login form — pairs well with the lockout patterns from our Juice Shop exercise.

6. A Web Application Firewall (WAF) as a second layer.
Tools like OWASP ModSecurity Core Rule Set can catch known injection patterns at the network edge — useful as defense-in-depth, but never a substitute for parameterized queries in your own code.


Testing Your Own Fix

Once you've patched a query, verify it the same way an attacker would probe it — against your own local instance only:

  1. Spin up your app locally with a test database.
  2. Try the classic ' OR '1'='1 payload in every input field that touches a query.
  3. Confirm the app returns a normal "invalid credentials" response, not a successful login or a raw SQL error.
  4. Repeat with a few more OWASP-documented payloads from the SQL Injection Prevention Cheat Sheet.

If you want a structured practice ground before touching a real project, OWASP Juice Shop has several SQL injection challenges built specifically to teach this — find the vulnerability, then go fix an identical pattern in your own codebase.


The One-Line Takeaway

If there's a single habit to walk away with: user input is always data, never code. Every parameterized query, every prepared statement, every ORM call that isn't hand-building raw SQL strings — they all exist to enforce that one boundary. Get that right and SQL injection stops being a threat model you have to think about.


Building this into your own Flask or Express project? Happy to review your query layer — drop your issue on the comment section and I'll flag anything that's still concatenating input directly into SQL.