security/sql-injection-how-a-quote-mark-in-a-login-form-can-rewrite-the-databas.md

SQL injection: how a quote mark in a login form can rewrite the database's question

Security · 5 min read ·

A login form builds a database question out of the words you type. If it glues your text straight into the question, your text can rewrite the question.

This article shows how SQL injection works with a small Python and SQLite login check: a quote character that closes a string early, an always-true condition, and a comment that deletes the password test. Then it shows the fix, placeholders, and lets you try both versions live in the page.

A dark code box showing an SQL query where a typed quote and double dash cross out the password check

You type your name and password into a login form and press the button. Somewhere on the server, a program asks a database: is there a row in the users table with this name and this password? The question is written in SQL (Structured Query Language), the language most databases understand. The program builds that question out of text, and how it builds the text decides whether a stranger can rewrite the question.

Building a question out of pieces

The most obvious way to build the question is to glue the user's words into a string. Here is a small login check in Python, using the built-in sqlite3 module and a throwaway database that lives in memory. (Storing plain passwords is a shortcut for this demo only; real sites store a salted hash.)

import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE users (name TEXT, password TEXT)")
db.executemany("INSERT INTO users VALUES (?, ?)",
               [("ana", "pw-ana"), ("bob", "pw-bob"), ("admin", "s3cret")])

def login_bad(name, pw):
    sql = f"SELECT name FROM users WHERE name = '{name}' AND password = '{pw}'"
    return db.execute(sql).fetchall()

The single quotes around {name} tell SQL "everything in here is a piece of text, not an instruction". For normal input it works:

login_bad("ana", "pw-ana")   # [('ana',)]
login_bad("ana", "wrong")    # []

When the data starts giving orders

The database never sees "a name" and "a password". It receives one long string and has to decide where the text ends and the instructions begin. The only signal is the quote character. So what if the password a user types contains a quote?

login_bad("ana", "x' OR '1'='1")

The finished string becomes:

SELECT name FROM users WHERE name = 'ana' AND password = 'x' OR '1'='1'

The quote after the x closed the text early, and the rest of the input was read as SQL. Now there is an OR '1'='1', which is always true. SQL evaluates AND before OR, so the condition means "(right name and password) or (1 equals 1)". When we ran it, it returned all three users: [('ana',), ('bob',), ('admin',)], with no correct password in sight.

The second classic trick uses --, which in SQL starts a comment. Typing admin' -- as the name turns the query into ... WHERE name = 'admin' --' AND password = 'anything'. Everything after -- is ignored, so the password check disappears and the result was [('admin',)]. This is called SQL injection: input that was meant to be data gets injected into the instructions.

It is not only an attacker's problem. A visitor named O'Brien breaks the same code by accident: Python raised OperationalError near "Brien": syntax error. If an honest name crashes your query, a dishonest one can steer it.

Try it yourself

The demo below holds the same three users. It builds the query in either of the two ways, then shows the SQL and which rows come back. It uses a tiny SQL checker written for this page that understands just this one query shape, so it is a model of a real database, not a real one. Pick a preset, or type your own input.

Switch to Use placeholders and press each preset again. The "always true" and "comment it out" inputs now just fail to match anyone, and O'Brien no longer crashes anything; he is simply not in the table.

The fix: keep the question and the answers apart

The safe version never glues anything. It sends the question with holes in it, and the values separately:

def login_good(name, pw):
    return db.execute(
        "SELECT name FROM users WHERE name = ? AND password = ?",
        (name, pw)).fetchall()

login_good("ana", "x' OR '1'='1")   # []
login_good("admin' --", "anything") # []
login_good("ana", "pw-ana")         # [('ana',)]

This is called a parameterized query (or prepared statement). The ? marks are placeholders. The database reads the instructions first, so it already knows the shape of the question, and then fills the holes with values that can only ever be values. A quote inside a value is just a character. The placeholder symbol depends on the tool: ? in SQLite, %s in the Python driver for PostgreSQL, $1 in PostgreSQL's own syntax. The idea is the same everywhere, in every language.

What to do in your own code

First, never build SQL by joining strings with user input, whether with +, an f-string, or a template literal. Use your database library's placeholders every time, even for input you think is safe. Second, if you use an ORM (a library that writes SQL for you), its normal methods usually use placeholders, but most also offer a "raw SQL" escape hatch, and the same rule applies there. Third, placeholders only work for values. You cannot pass a table or column name as a parameter, so if a sort column comes from the user, check it against a fixed list of allowed names in your own code.

Two more layers help, but neither replaces placeholders. Give the database account your app uses only the permissions it needs, so a mistake can do less damage. And check input sensibly (an age should be a number), which catches errors early. Injection is a long-standing entry on OWASP's list of the most common web security risks, and the cause is always the same thing you saw above: data and instructions travelling in one string.

$ ls security/

see all →
security/cross-site-scripting-how-a-comment-box-can-run-someone-else-s-code.md Cross-site scripting: how a comment box can run someone else's code A comment box looks harmless until someone types a tiny program instead of a comment. This article explains cross-site scripting, or XSS… 5 min read · 28 Oct security/two-factor-codes-how-your-phone-and-a-website-agree-on-six-digits-with.md Two-factor codes: how your phone and a website agree on six digits without talking Your authenticator app shows six digits and the website expects the same six, yet the two never talk. How? Both sides start from the same… 5 min read · 24 Oct security/password-strength-why-length-beats-a-capital-letter-and-a-symbol.md Password strength: why length beats a capital letter and a symbol Everyone says passwords need a capital, a number and a symbol, and also that they should be long. Which one actually matters more? The… 6 min read · 18 Oct security/password-hashing-why-a-website-never-stores-your-password-and-what-a-s.md Password hashing: why a website never stores your password, and what a salt changes Websites should never store your password, only a hash made from it. But two users with the same password get the same hash, and a stolen… 5 min read · 15 Oct security/caesar-cipher-why-25-guesses-break-it-and-what-real-encryption-does-in.md Caesar cipher: why 25 guesses break it, and what real encryption does instead The Caesar cipher swaps each letter for one a fixed distance along the alphabet. Slide the key yourself, then play the attacker and break… 5 min read · 12 Oct security/two-factor-codes-how-your-phone-and-a-website-agree-on-6-digits.md Two-factor codes: how your phone and a website agree on 6 digits Your phone shows a 6-digit code that changes every 30 seconds, yet it never talks to the website. How do both sides agree on the number?… 5 min read · 9 Oct