Case Study 1 — The Login Form That Let Anyone In

SQL injection isn't a theoretical risk — it's how data gets stolen and logins get bypassed. A login form built with string-concatenated SQL let an attacker log in as anyone (and dump the whole user table) with a single crafted input. Parameterized queries would have made it impossible.

Background

An app authenticated users with a query that checked email and password, built by concatenating the form inputs into the SQL string:

# ❌ string-concatenated SQL
email = request.form["email"]
password = request.form["password"]
cur.execute(
    "SELECT * FROM users WHERE email = '" + email + "' AND password = '" + password + "';"
)
user = cur.fetchone()
if user:
    log_in(user)        # any matching row → authenticated

It worked for normal logins. Then a security researcher (or, in the real version, an attacker) typed an unusual "password."

The attack

The attacker entered a normal-looking email and, as the password:

' OR '1'='1

The concatenation produced this SQL:

SELECT * FROM users WHERE email = 'someone@example.com' AND password = '' OR '1'='1';

Because '1'='1' is always true and OR has lower precedence, the WHERE matches every row. fetchone() returned a user, and the attacker was logged in — without knowing any password. With a slightly different payload (' OR '1'='1' LIMIT 1 --), they could log in as a specific high-privilege user. And with a UNION-based injection in a search field elsewhere, the same vulnerability class let them dump the entire users table — emails, password hashes, everything. One unescaped input, total compromise.

This is SQL injection: user input concatenated into SQL is executed as SQL, so the attacker controls the query. It's been at or near the top of the OWASP web-vulnerability list for two decades, precisely because string-built SQL is so common and so catastrophic.

The fix: parameterized queries

Send the SQL and the values on separate channels so input can never be interpreted as SQL:

# ✅ parameterized — input is data, never code
cur.execute(
    "SELECT * FROM users WHERE email = %s AND password_hash = %s;",
    (email, hash_password(password)),
)

Now the ' OR '1'='1 payload is treated as a literal password string to compare against — it matches nothing, and the login correctly fails. The attacker has no way to break out of the value into the query structure, because the value never becomes part of the SQL text. Injection is impossible, not merely unlikely.

(The team also fixed a second sin visible above: storing/comparing plaintext passwords. Passwords must be hashed with a strong algorithm (bcrypt/argon2), never stored or compared in plaintext — Chapter 32. The query fix stops injection; the hashing fix protects the credentials themselves.)

The analysis

  1. String-concatenated SQL is a security hole, always. Any time user input is glued into a SQL string, an attacker can craft input that changes the query's meaning — bypassing auth, reading other users' data, modifying or destroying tables. There is no "safe" concatenation; quoting/escaping by hand is fragile and gets bypassed.

  2. Parameterized queries make injection structurally impossible. The value travels separately from the SQL and is bound as data — it can never become executable. This isn't a mitigation that reduces risk; it eliminates the vulnerability class. It's also usually more convenient than concatenation.

  3. The rule is absolute: never put external input in the SQL string. Not "escape it carefully," not "validate it first and then concatenate" — parameterize. Validation is good defense-in-depth, but parameterization is the actual fix, for every value, every time.

  4. One vulnerable query is enough. The app had many queries; this one concatenated login query compromised everything. Injection-safety must be universal — which is exactly why isolating SQL in a parameterized repository layer (this chapter) and reviewing it matters.

  5. Defense in depth. Parameterize (stop injection), hash passwords (protect credentials), apply least privilege (limit blast radius, Chapter 32), and validate input (reject obvious garbage). The query fix is necessary but not the whole security story (Chapter 32).

Discussion questions

  1. Walk through how ' OR '1'='1 turns the login query into one that matches every row.
  2. Why does the parameterized version make that payload harmless?
  3. Why is "escape the input by hand" not an acceptable alternative to parameterization?
  4. The example also stored plaintext passwords. Why is that a separate, serious problem, and what's the fix?
  5. ⭐ How does isolating SQL in a parameterized repository layer reduce the chance that one concatenated query slips through?