Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Today we will be exploring Building, Breaking and fixing a website with SQL Injection.

Setting Up Your Environment

We are going to create a safe, isolated space for this project using a virtual environment

1. Create your project folder

Pick a safe location to store your work.

Inside of this folder, create an sqli-lab folder.

2. Install Python (if you have not already)

Open your web browser and go to python.org/downloads and download the latest stable version of Python for your OS.

Important

Windows Users: When running the installer, make sure to check the box that says Add python.exe to PATH before clicking install. This is the most common cause of issues for beginners!

3. Verify Python is installed

Open your terminal and type the following command. Press Enter after typing it.

=== “Windows (PowerShell)”

python --version

=== “macOS/Linux (Terminal)”

python3 --version

You should see something along the lines of Python 3.XX.X. If you get an error, see Troubleshooting.

  1. Open Visual Studio Code
  2. Go to File > Open Folder
  3. Open the sqli-lab folder that you just created.
  4. Open a new terminal inside of VS Code (Terminal > New Terminal)

Your terminal should now be opened inside of your sqli-lab folder.

Checkpoint 1

  • I can run python --version (Windows) or python3 --version (macOS/Linux) and see a version number.
  • I have opened my sqli-lab folder in VS Code.
  • I have a terminal open inside of it.

Note

What is a virtual environment? It’s like a clean room for this project. Any packages that we install (like Flask) will only exist for this lab, and won’t interfere with any other Python projects on your computer!

Building The Lab Application

Now for the fun part, building our app.

1. Create the project structure

In your terminal, create the folders and files for our project.

Create the following folder structure:

    sqli-lab/
    ├── instance/     (Will hold our database file)
    ├── static/
    │   └── css/      (Styles for our page)
    └── templates/    (HTML for our page)

2. Create a Virtual Environment

=== “Windows (PowerShell)”

python -m venv .venv

=== “macOS/Linux (Terminal)”

python3 -m venv .venv

This creates a .venv folder. This is our “clean room”.

3. Activate the Virtual Environment

We must activate it every time we want to run this project in a new terminal.

=== “Windows (PowerShell)”

.\.venv\Scripts\activate

=== “macOS/Linux (Terminal)”

source .venv/bin/activate

When activated, you should see (.venv) at the beginning of your terminal line. This means it is working!

Tip

If you get an error on Windows about script execution being disabled, see the Troubleshooting section for the fix.

4. Create the requirements

Create a file named requirements.txt inside of sqli-lab/. Copy and paste this into it.

Flask==3.0.3
gunicorn==23.0.0

What is this? This tells Python which packages to install. Flask is the web framework that we are using to make our website.

Install them with the following command:

pip install -r requirements.txt

You should see output showing Flask and its dependencies being installed.

5. Create the files

Note

Read the comments in the code! They are extremely important for this lab. We have marked the vulnerable parts very explicitly.

init_db.py

Create this file in sqli-lab/. This creates our database and adds test users to it.

import sqlite3
import os

DB_PATH = os.path.join("instance", "users.db")
os.makedirs("instance", exist_ok=True)

conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()

cur.execute("""
CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    username TEXT NOT NULL UNIQUE,
    password TEXT NOT NULL
)
""")

# Clear old data for repeatable demos
cur.execute("DELETE FROM users")

# Seed a known user
cur.execute("INSERT OR IGNORE INTO users (username, password) VALUES (?, ?)", ("admin", "supersecret123"))
cur.execute("INSERT OR IGNORE INTO users (username, password) VALUES (?, ?)", ("test", "password123"))

conn.commit()
conn.close()

print(f"[+] Database initialized at {DB_PATH}")
print("[+] Users created: admin / test")

Explanation: This creates a table called users with id, username and password. It creates two test accounts for us to use.

app.py

Create this file in sqli-lab/. This is our web application.

import os
import sqlite3
from flask import Flask, render_template, request

app = Flask(__name__)
DB_PATH = os.path.join("instance", "users.db")

@app.route("/", methods=["GET", "POST"])
def login():
    error = None
    query_shown = None

    if request.method == "POST":
        username = request.form.get("username", "")
        password = request.form.get("password", "")

        conn = sqlite3.connect(DB_PATH)
        cur = conn.cursor()

        query = f"SELECT id, username FROM users WHERE username = '{username}' AND password = '{password}'"
        query_shown = query

        try:
            cur.execute(query)
            user = cur.fetchone()
        except Exception as e:
            error = f"SQL Error: {e}"
            user = None
        finally:
            conn.close()

        if user:
            return render_template("login.html", success=f"Logged in as {user[1]} (id={user[0]})", query=query_shown)
        else:
            if not error:
                error = "Invalid username or password."
            return render_template("login.html", error=error, query=query_shown)

    return render_template("login.html")

if __name__ == "__main__":
    # Only for local testing
    app.run(debug=True)

templates/login.html

Create this file in sqli-lab/templates/.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SQLi Demo - Login</title>
    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
    <main class="container">
        <header>
            <h1>SQL Injection Demo</h1>
            <p class="warning">FOR EDUCATIONAL USE ONLY – Run locally in a lab environment.</p>
        </header>

        <section class="card">
            <h2>Login</h2>
            <form method="POST" action="/">
                <label for="username">Username</label>
                <input type="text" id="username" name="username" autocomplete="off" required>

                <label for="password">Password</label>
                <input type="text" id="password" name="password" autocomplete="off" required>

                <button type="submit">Login</button>
            </form>

            {% if error %}
                <div class="alert error">{{ error }}</div>
            {% endif %}

            {% if success %}
                <div class="alert success">{{ success }}</div>
            {% endif %}

            {% if query %}
                <details class="query-box" open>
                    <summary>Executed SQL Query (shown for learning)</summary>
                    <code>{{ query }}</code>
                </details>
            {% endif %}
        </section>

        <section class="card">
            <h2>Try these payloads (Only on this local lab)</h2>
            <ul>
                <li><code>admin' --</code> in username, anything in password (comment out password check)</li>
                <li><code>' OR '1'='1' --</code> in username, anything in password (always true)</li>
                <li><code>' OR 1=1 LIMIT 1 --</code> similar effect</li>
            </ul>
            <p>Watch the "Executed SQL Query" block to see how your input changes it.</p>
        </section>
    </main>
</body>
</html>

static/css/style.css

Create this file in sqli-lab/static/css/.

:root {
    --bg: #0f1026;
    --card: #1d1f33;
    --text: #f4f4f9;
    --subtle: #b8b8d1;
    --error: #ff5f5f;
    --success: #3fb984;
    --border: #2f315c;
}

* {
    box-sizing: border-box;
    font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
}

body {
    margin: 0;
    background: radial-gradient(circle at top, #2a2d5a, var(--bg));
    color: var(--text);
    line-height: 1.6;
}

.container {
    max-width: 720px;
    margin: 0 auto;
    padding: 2rem 1.5rem;
    display: flex;
    flex-direction: column;
    gap: 1.5rem;
}

header {
    text-align: center;
}

header h1 {
    margin-bottom: 0.25rem;
}

.warning {
    color: #ffb86c;
    margin: 0;
}

.card {
    background: var(--card);
    border: 1px solid var(--border);
    border-radius: 12px;
    box-shadow: 0 40px 60px -40px rgba(63, 185, 132, 0.25);
    padding: 2rem;
    display: flex;
    flex-direction: column;
    gap: 1rem;
}

.card h2 {
    margin: 0;
}

form {
    display: flex;
    flex-direction: column;
    gap: 0.8rem;
}

label {
    font-weight: 600;
}

input {
    padding: 0.7rem 0.9rem;
    border-radius: 8px;
    border: 1px solid transparent;
    background: rgba(15, 16, 38, 0.8);
    color: var(--text);
}

input:focus {
    outline: none;
    border-color: rgba(63, 185, 132, 0.8);
}

button {
    margin-top: 0.5rem;
    padding: 0.8rem;
    border-radius: 999px;
    border: none;
    background: linear-gradient(90deg, #3fb984, #22d3ee);
    color: var(--bg);
    font-weight: 700;
    cursor: pointer;
}

.alert {
    padding: 0.8rem 1rem;
    border-radius: 8px;
    border: 1px solid var(--border);
}

.error {
    background: rgba(255, 95, 95, 0.15);
    color: var(--error);
}

.success {
    background: rgba(63, 185, 132, 0.18);
    color: var(--success);
}

.query-box {
    background: rgba(15, 16, 38, 0.8);
    border-radius: 8px;
    border: input border 1px solid var(--border);
    padding: 1rem;
}

.query-box code {
    display: block;
    margin-top: 0.8rem;
    white-space: pre-wrap;
    word-break: break-word;
    color: var(--subtle);
}

ul {
    margin: 0;
    display: flex;
    flex-direction: column;
    gap: 0.6rem;
}

code {
    font-family: "Cascadia Code", "Fira Code", monospace;
}

Checkpoint 2

  • All files have been created in sqli-lab/
  • I have activated my virtual environment ((.venv))
  • pip install -r requirements.txt has completed without errors
  • I can see all of the files inside of VS Code

Running The App For The First Time

Now that we’ve built everything, let’s initialize the database and run the app for the first time.

1. Initialize the database

Make sure that you are still in the sqli-lab/ directory and your virtual environment is activated.

python init_db.py

You should see the following output:

[+] Database initialized at instance\users.db  (Windows)
[+] Database initialized at instance/users.db   (macOS/Linux)
[+] Users created: admin / test

Tip

If instance/users.db appears in your file explorer now, that’s correct! Do not share this file or commit it anywhere public.

2. Start the web server

Now run the app:

python app.py

You should see output similar to this:

 * Running on http://127.0.0.1:5000
 * Debug mode: on

Important

While this is running, your terminal is locked into this server. Do not close this terminal yet. If you need to run other commands, open a new terminal in VS Code and activate your virtual environment again.

3. Open it in your browser

Open your web browser and go to http://127.0.0.1:5000.

You should see the “SQL Injection Demo” login page, complete with the payload examples at the bottom.

4. Test a normal login

Try the valid credentials:

  • Username: admin
  • Password: supersecret123

You should be greeted with a success message and the “Executed SQL Query” box will display the query that was ran.

Now try logging in with incorrect credentials (e.g admin and wrongpassword). You should see “Invalid username or password.”

Checkpoint 3

  • init_db.py ran successfully and created the database
  • The server is running on http://127.0.0.1:5000
  • I can login with admin / supersecret123 and get a success message
  • I can see the executed SQL query in the query box

Note

To stop the server at any time, go back to the terminal that it is running in and press Ctrl + C.

Exploiting our Application

Prediction Exercise

Before trying any payload, answer this to yourself.

If I type test as the username and password123 as the password, what would the query look like based off of app.py’s vulnerable line?

Take a look back at the line: f"SELECT id, username FROM users WHERE username = '{username}' AND password = '{password}'"

With test and password123 it would become:

SELECT id, username FROM users WHERE username = 'test' AND password = 'password123'

This matches! The single quotes are added by our code, and our input goes inside of them. This is the key.

Exploit 1: Comment out the rest of the query (--)

The SQL comment -- will tell the database to ignore everything after it on that line.

  1. Go to the login page.
  2. For Username, type: admin' --
  3. For Password, type: anything (it doesn’t matter, it will be commented out!)
  4. Click Login

Observe

Look at the query box. It should look something like this:

SELECT id, username FROM users WHERE username = 'admin' -- ' AND password = 'anything'

See what happened? Our input injected admin' which closed the username’s quote, and -- commented out AND password = '...'. The password check has been completely removed! Because of this, we are logged in as admin without knowing the password.

What happened (Breakdown)

  • Original structure: WHERE username = '...input...' AND password = '...input...'
  • We made it: WHERE username = 'admin' followed by a comment, so the AND password = ... is ignored by the database.

Exploit 2: Make the condition always true (OR '1'='1')

This is one of the most famous payloads. '1'='1' is always true in SQL.

  1. Clear the form (refresh the page)
  2. For Username, type: ' OR '1'='1' --
  3. For Password, type: anything
  4. Click Login

Observe

Your query should look similar to this:

SELECT id, username FROM users WHERE username = '' OR '1'='1' -- ' AND password = 'anything'

The WHERE clause is now (username = '') OR ('1'='1'). '1'='1' is always true, so this condition is true for at least the first row that it finds. Depending on how the database returns it, this will often log us in (in this case it will authenticate as a user).

What happened (Breakdown)

  • We closed the quotes with ', added OR '1'='1' which makes the logic always evaluate to true.
  • We commented out the rest with -- to ignore the password check entirely.

Exercise Time

Before moving onto the fixes, try to answer these questions. Do not look up the answers, reason them out by looking at the query box!

  1. Why did admin' -- work, even though we put garbage into the password field?
  2. Why does '1'='1' make the login succeed?
  3. If I change the payload to ' OR '1'='0' --, would it succeed or fail? Try it and see! (Predict first, then test.)

Checkpoint 4

  • I was able to log in as admin without the password using admin' --
  • I was able to bypass the login using ' OR '1'='1' --
  • I have looked at the query box for both payloads and can explain, in my own words, why the query changed
  • I have attempted the prediction exercise above

Note

Take a moment to appreciate what we just did. This is the bug in action. The fix is to make it so that no matter what somebody types, it can never change the structure of the SQL query. This is exactly what parameterized queries do.

Fixing It v1

For every attempt, make the change to app.py, restart the server (Ctrl+C and python app.py again), test your payloads, and document the result.

Attempt 1: Blacklist dangerous keywords (OR, --, ;, UNION)

Idea: If I block the words that are used in the payloads, they can’t work.

Change app.py (Revert attempt 1 first, or just change it)

Revert the replace lines first. Now add a check:

        username = request.form.get("username", "")
        password = request.form.get("password", "")

        # INCORRECT FIX - DEMO ONLY
        blacklist = ["OR", "or", "UNION", "union", "--", ";", "#", "/*", "*/"]
        combined = username + password
        for word in blacklist:
            if word in combined:
                error = "Invalid input detected!"
                return render_template("login.html", error=error)
        
        conn = sqlite3.connect(DB_PATH)
        cur = conn.cursor()

        query = f"SELECT id, username FROM users WHERE username = '{username}' AND password = '{password}'"
        ...

Test it

Try ' OR '1'='1' -- again.

You will get “Invalid input detected!” for this specific payload. However, there are ways to bypass this.

Any ideas on how we might get around this?

Take some time, think about what exactly is being blocked and what might not be. Maybe this fix isn’t as safe as we first thought?

Wait here until we are all ready to move forward.

Breaking It V1

Bypass it (maybe that was a bad fix)

Try obfuscating the OR. For example:

' oR '1'='1

Further reasons why that is a bad fix

  • There are infinite payloads. An attacker can find any variation to bypass a blacklist.
  • False positives: If a legitimate user had or in their password (uncommon, but possible), they would be blocked.
  • Obfuscation: Comments /**/, encoding, or different syntax can all be used to bypass it.

This is security through obscurity. It is trivial to get around with enough effort.

Undo your changes

Before moving onto the correct fix, revert app.py back to its original vulnerable state (the one from Chapter 2) or just remove the “fixes” that we just added. We will be replacing the logic entirely in the next chapter.

Fixing It v2 (Parameterized Queries)

Now that we’ve proven why the incorrect fix failed, we’ll apply the correct, robust fix. This is the standard that should be used in any application that talks to a database.

Important

The fix is to stop concatenating user input into the query and instead, pass it separately to the database driver to be safely parameterized.

The Fix

We will be using sqlite3’s ? placeholder for parameterized queries.

Change app.py

Replace the entire vulnerable login logic with this correct version.

import os
import sqlite3
from flask import Flask, render_template, request

app = Flask(__name__)
DB_PATH = os.path.join("instance", "users.db")

@app.route("/", methods=["GET", "POST"])
def login():
    error = None
    query_shown = None

    if request.method == "POST":
        username = request.form.get("username", "")
        password = request.form.get("password", "")

        conn = sqlite3.connect(DB_PATH)
        cur = conn.cursor()

        # CORRECT: Use parameterized queries (prepared statements)
        # The SQL structure is fixed. User input is passed as data.
        query = "SELECT id, username FROM users WHERE username = ? AND password = ?"
        query_shown = query  # For learning purposes only, no user input is concatenated here

        try:
            # Pass the values as a tuple. The driver safely handles them.
            cur.execute(query, (username, password))
            user = cur.fetchone()
        except Exception as e:
            # For a lab this is okay to show, however see Chapter 9 for production advice
            error = f"SQL Error: {e}"
            user = None
        finally:
            conn.close()

        if user:
            return render_template("login.html", success=f"Logged in as {user[1]} (id={user[0]})", query=query_shown)
        else:
            if not error:
                error = "Invalid username or password."
            return render_template("login.html", error=error, query=query_shown)

    return render_template("login.html")

if __name__ == "__main__":
    # Only for local testing
    app.run(debug=True)

What’s different?

VulnerableCorrect
query = f"... '{username}' ... '{password}' ..." and cur.execute(query)query = "... ? ... ?" and cur.execute(query, (username, password))
User input is glued into the SQL string.User input is sent separately as parameters. The database treats it as data, not code.
Attackers can change the query’s structure.The query’s structure is fixed. No matter what characters are put in, they cannot alter WHERE, add OR, or add comments to change logic.

Test the fix

  1. Save app.py
  2. Stop the server if it’s running (Ctrl+C) and start it again (python app.py)
  3. Go to http://127.0.0.1:5000

Test 1: Normal login still works

Login with admin / supersecret123. This should still succeed. The functionality is preserved!

Test 2: The payloads now fail

Try admin' -- with any password. It will fail with “Invalid username or password.”

Try ' OR '1'='1' -- with any password. It will fail.

Observe the query box

The query box will now show:

SELECT id, username FROM users WHERE username = ? AND password = ?

Notice that there is no user input inside of it at all. The database is doing the substitution safely behind the scenes. This is correct!

Bonus Test (Quotes are now safe!)

Try using a password with a single quote if you wanted to (for example, if you changed the database, but we can also just observe). With parameterization, quotes are treated as a normal character, not a string delimiter. This means our users can have safe, normal passwords without us breaking anything.

Checkpoint 5

  • I have replaced the vulnerable logic with parameterized queries
  • Normal login (admin/supersecret123) still works
  • admin' -- no longer bypasses the login
  • ' OR '1'='1' -- no longer bypasses the login
  • The query shown is ... username = ? AND password = ? with no concatenated user input
  • I can explain why this prevents SQL Injection (data vs code)

[!SUCCESS] Congratulations! You’ve fixed the root cause. This is the most important takeaway from this lab. Wherever user input meets a database query, you want to use parameterized queries (or a secure ORM that uses them under the hood).

Hardening and Best Practices

Parameterization fixes SQL Injection. However, a real login system needs more layers of defense. Here are beginner-friendly best practices to be aware of for future projects.

Note

We won’t be implementing all of these into this lab (as to not overcomplicate it), however it’s critical to know about them.

PracticeWhy?Beginner Example
Hash passwordsIf your database ever gets leaked, plain text passwords are exposed to everyone.Use werkzeug.security.generate_password_hash() and check_password_hash() when storing and verifying passwords.
Don’t leak SQL errorsShowing SQL Error: ... to users can reveal information about your database structure (table names, schema, etc) to attackers.Log the error to your server console, and show a generic error to the user (e.g “Something went wrong. Please try again.”).
Use the principle of least privilegeThe database user that your app connects with should only be able to do what it needs to do.If your app only needs to read users for login, don’t give it permission to DELETE, DROP TABLE, or create users.
Rate limitingPrevents brute force attacks where someone tries to guess passwords over and over again.Tools like Flask-Limiter can limit how many login attempts a user/IP can make within a time window.
Use ORMsObject Relational Mappers (like SQLAlchemy) use parameterized queries by default and make it much harder to accidentally concatenate.Great “next step” once you’re more comfortable with Python and SQL.
Consider MFAMulti-Factor Authentication adds another layer of security even if a password is guessed or leaked.Beyond the scope of this lab, but incredibly important.

Quick Exercise

Look at our fixed app.py. Find the line where we do error = f"SQL Error: {e}".

Warning

For a production application, change this to log the error server-side (e.g print(e) or a proper logger) and return something like error = "An unexpected error occurred." to the user.

You don’t have to change it for this local lab if you want to keep debugging easy, however it’s good to be aware of this.

Checkpoint 6

  • I know why plain text passwords are dangerous
  • I know why exposing SQL errors is dangerous
  • I know that parameterization isn’t the only thing to do, it’s the fix for SQL Injection

Wrap Up, Ethics & Next Steps

What we’ve accomplished

You did it! You went from building the lab, all the way to fixing the vulnerability. Let’s recap:

  • Understood the bug: String concatenation allows user input to change SQL query structure.
  • Exploited it safely: Saw how -- and OR '1'='1' can bypass authentication in a local lab.
  • Tested incorrect fixes: Proved that escaping, blacklisting, and whitelisting are not robust solutions.
  • Fixed it correctly: Used parameterized queries to separate code and data, preventing SQLi.
  • Learned defense in depth: Looked at hardening practices for the future.

Ethics Reminder (Read again)

Warning

  • Only use this knowledge for good. Find and fix vulnerabilities, don’t exploit ones you don’t have permission for.
  • Keep this local. Do not host the vulnerable version online.
  • Responsible disclosure: If you ever find a vulnerability in a real system, report it through the correct, authorized channel.
  • Follow the law: Unauthorized testing of systems is illegal in most jurisdictions.

Cleanup (Optional)

If you’re done with the lab, you can clean up.

  1. Stop the server with Ctrl+C
  2. Deactivate the virtual environment with deactivate
  3. You can delete the entire sqli-lab/ folder if you wish to remove it. The database (instance/users.db) and __pycache__ will be deleted with it.

Next Steps To Learn More

If you want to expand your knowledge, these are all free and beginner friendly resources:

ResourceWhat it’s good for
OWASP SQL InjectionThe industry standard explanation and prevention methods.
PortSwigger Web Security AcademyInteractive, in-browser labs that are incredibly well made. Start with their SQLi section!
Flask DocumentationTo learn more about Flask and expand this application.
SQLite DocumentationTo learn more about the database that we used.

Now that you know the fix, try to expand this lab yourself!

  • Add a /fixed route that explicitly uses parameterized queries (to have vulnerable and fixed side by side)
  • Add password hashing using werkzeug.security
  • Change the app to not display SQL errors to the user, only log them
  • Add a new page that uses a LIKE query and fix it with parameterization (to see it works in different contexts)

You now have the foundation to do all of these safely!

[!SUCCESS] Great job completing this lab. Remember, the goal isn’t to be able to exploit, it’s to be able to build securely.

Glossary

TermDefinition
BlacklistTrying to block specific words/characters. Not secure against SQLi.
ConcatenationGluing strings together (e.g putting user input directly into a query). The root cause of this vulnerability.
DatabaseA structured collection of data (like our users.db file).
Debug Mode (debug=True)A Flask setting for development that gives helpful errors. Never use in production.
FlaskA Python web framework used to make our website.
InjectionWhen untrusted input is interpreted as code instead of data.
Localhost / 127.0.0.1Your own computer. The only place that this lab should ever be ran.
ORM (Object Relational Mapper)A library that maps database tables to objects in code. Often uses parameterized queries by default.
Parameterization / Prepared StatementSeparating the SQL query structure from the data. The secure fix for SQLi.
PayloadMalicious input that is crafted to exploit a vulnerability.
Placeholder (?)A symbol used in parameterized queries where data will be safely inserted.
QueryA request sent to a database (e.g SELECT ... FROM ...).
SQL (Structured Query Language)The language used to communicate with databases.
SQL Injection (SQLi)Injecting SQL code via user input due to string concatenation.
StringText (e.g "admin", "password123").
Terminal / Command LineA text based interface to run commands on your computer.
Virtual Environment (venv)An isolated space to install packages for a specific project.
WhitelistOnly allowing specific, known good inputs. Can be okay for restriction, but isn’t the fix for SQLi on its own.

Full File Reference

For your convenience, here are all of the files referenced throughout the book in one place. Use this to verify that your files match exactly.

Note

All of these are for the vulnerable version that we built in chapter 4. Remember to replace app.py with the fixed version from chapter 8 after testing!

sqli-lab/requirements.txt

Flask==3.0.3
gunicorn==23.0.0
__pycache__/
*.pyc
*.db
instance/
.env
venv/
.venv/

sqli-lab/init_db.py

import sqlite3
import os

DB_PATH = os.path.join("instance", "users.db")
os.makedirs("instance", exist_ok=True)

conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()

cur.execute("""
CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    username TEXT NOT NULL UNIQUE,
    password TEXT NOT NULL
)
""")

cur.execute("DELETE FROM users")

cur.execute("INSERT OR IGNORE INTO users (username, password) VALUES (?, ?)", ("admin", "supersecret123"))
cur.execute("INSERT OR IGNORE INTO users (username, password) VALUES (?, ?)", ("test", "password123"))

conn.commit()
conn.close()

print(f"[+] Database initialized at {DB_PATH}")
print("[+] Users created: admin / test")

sqli-lab/app.py (Vulnerable - For learning only)

import os
import sqlite3
from flask import Flask, render_template, request

app = Flask(__name__)
DB_PATH = os.path.join("instance", "users.db")

@app.route("/", methods=["GET", "POST"])
def login():
    error = None
    query_shown = None

    if request.method == "POST":
        username = request.form.get("username", "")
        password = request.form.get("password", "")

        conn = sqlite3.connect(DB_PATH)
        cur = conn.cursor()

        query = f"SELECT id, username FROM users WHERE username = '{username}' AND password = '{password}'"
        query_shown = query

        try:
            cur.execute(query)
            user = cur.fetchone()
        except Exception as e:
            error = f"SQL Error: {e}"
            user = None
        finally:
            conn.close()

        if user:
            return render_template("login.html", success=f"Logged in as {user[1]} (id={user[0]})", query=query_shown)
        else:
            if not error:
                error = "Invalid username or password."
            return render_template("login.html", error=error, query=query_shown)

    return render_template("login.html")

if __name__ == "__main__":
    app.run(debug=True)

sqli-lab/app.py (Fixed - Correct)

import os
import sqlite3
from flask import Flask, render_template, request

app = Flask(__name__)
DB_PATH = os.path.join("instance", "users.db")

@app.route("/", methods=["GET", "POST"])
def login():
    error = None
    query_shown = None

    if request.method == "POST":
        username = request.form.get("username", "")
        password = request.form.get("password", "")

        conn = sqlite3.connect(DB_PATH)
        cur = conn.cursor()

        query = "SELECT id, username FROM users WHERE username = ? AND password = ?"
        query_shown = query

        try:
            cur.execute(query, (username, password))
            user = cur.fetchone()
        except Exception as e:
            error = f"SQL Error: {e}"
            user = None
        finally:
            conn.close()

        if user:
            return render_template("login.html", success=f"Logged in as {user[1]} (id={user[0]})", query=query_shown)
        else:
            if not error:
                error = "Invalid username or password."
            return render_template("login.html", error=error, query=query_shown)

    return render_template("login.html")

if __name__ == "__main__":
    app.run(debug=True)

sqli-lab/templates/login.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SQLi Demo - Login</title>
    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
    <main class="container">
        <header>
            <h1>SQL Injection Demo</h1>
            <p class="warning">FOR EDUCATIONAL USE ONLY – Run locally in a lab environment.</p>
        </header>

        <section class="card">
            <h2>Login</h2>
            <form method="POST" action="/">
                <label for="username">Username</label>
                <input type="text" id="username" name="username" autocomplete="off" required>

                <label for="password">Password</label>
                <input type="text" id="password" name="password" autocomplete="off" required>

                <button type="submit">Login</button>
            </form>

            {% if error %}
                <div class="alert error">{{ error }}</div>
            {% endif %}

            {% if success %}
                <div class="alert success">{{ success }}</div>
            {% endif %}

            {% if query %}
                <details class="query-box" open>
                    <summary>Executed SQL Query (shown for learning)</summary>
                    <code>{{ query }}</code>
                </details>
            {% endif %}
        </section>

        <section class="card">
            <h2>Try these payloads (Only on this local lab)</h2>
            <ul>
                <li><code>admin' --</code> in username, anything in password</li>
                <li><code>' OR '1'='1' --</code> in username, anything in password</li>
                <li><code>' OR 1=1 LIMIT 1 --</code> similar effect</li>
            </ul>
            <p>Watch the "Executed SQL Query" block to see how your input changes it.</p>
        </section>
    </main>
</body>
</html>

sqli-lab/static/css/style.css

:root {
    --bg: #0f1026;
    --card: #1d1f33;
    --text: #f4f4f9;
    --subtle: #b8b8d1;
    --error: #ff5f5f;
    --success: #3fb984;
    --border: #2f315c;
}

* {
    box-sizing: border-box;
    font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
}

body {
    margin: 0;
    background: radial-gradient(circle at top, #2a2d5a, var(--bg));
    color: var(--text);
    line-height: 1.6;
}

.container {
    max-width: 720px;
    margin: 0 auto;
    padding: 2rem 1.5rem;
    display: flex;
    flex-direction: column;
    gap: 1.5rem;
}

header {
    text-align: center;
}

header h1 {
    margin-bottom: 0.25rem;
}

.warning {
    color: #ffb86c;
    margin: 0;
}

.card {
    background: var(--card);
    border: 1px solid var(--border);
    border-radius: 12px;
    box-shadow: 0 40px 60px -40px rgba(63, 185, 132, 0.25);
    padding: 2rem;
    display: flex;
    flex-direction: column;
    gap: 1rem;
}

.card h2 {
    margin: 0;
}

form {
    display: flex;
    flex-direction: column;
    gap: 0.8rem;
}

label {
    font-weight: 600;
}

input {
    padding: 0.7rem 0.9rem;
    border-radius: 8px;
    border: 1px solid transparent;
    background: rgba(15, 16, 38, 0.8);
    color: var(--text);
}

input:focus {
    outline: none;
    border-color: rgba(63, 185, 132, 0.8);
}

button {
    margin-top: 0.5rem;
    padding: 0.8rem;
    border-radius: 999px;
    border: none;
    background: linear-gradient(90deg, #3fb984, #22d3ee);
    color: var(--bg);
    font-weight: 700;
    cursor: pointer;
}

.alert {
    padding: 0.8rem 1rem;
    border-radius: 8px;
    border: 1px solid var(--border);
}

.error {
    background: rgba(255, 95, 95, 0.15);
    color: var(--error);
}

.success {
    background: rgba(63, 185, 132, 0.18);
    color: var(--success);
}

.query-box {
    background: rgba(15, 16, 38, 0.8);
    border-radius: 8px;
    border: 1px solid var(--border);
    padding: 1rem;
}

.query-box code {
    display: block;
    margin-top: 0.8rem;
    white-space: pre-wrap;
    word-break: break-word;
    color: var(--subtle);
}

ul {
    margin: 0;
    display: flex;
    flex-direction: column;
    gap: 0.6rem;
}

code {
    font-family: "Cascadia Code", "Fira Code", monospace;
}

Troubleshooting

IssuePossible CauseFix
python or py not foundPython not installed or not added to PATH (Windows)Reinstall Python and check Add python.exe to PATH. Try py on Windows, python3 on macOS/Linux.
Can’t activate venv on Windows (script execution disabled)PowerShell’s execution policy is blocking scriptsOpen PowerShell as your user and run: Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser. Then try activating again. Only do this for your user account.
ModuleNotFoundError: No module named 'flask'Virtual environment not activated, or packages not installedActivate your venv ((.venv)) and run pip install -r requirements.txt again.
Port 5000 already in useSomething else is running on port 5000Close the other program, or run Flask on a different port: flask run --port 5001. Remember to go to the new port in your browser.
Database errorsCorrupted or missing databaseDelete instance/users.db and run python init_db.py again.
Page doesn’t load / connection refusedServer not runningMake sure the terminal with python app.py is still running and hasn’t crashed. Check for errors in that terminal.
Changes aren’t showing upBrowser cache or server not restartedHard refresh your browser (Ctrl+Shift+R or Cmd+Shift+R). If you changed app.py, restart the server with Ctrl+C and python app.py again.
Quotes being weird when copying payloadsSmart quotes from some documentsAlways type the payloads exactly as shown in the book. Avoid copying from rich text documents, copy from the code blocks.

Cheat Sheet

Commands

CommandPurpose
py -m venv .venv (Win) / python3 -m venv .venv (Mac/Linux)Create virtual environment
.\.venv\Scripts\activate (Win) / source .venv/bin/activate (Mac/Linux)Activate virtual environment
pip install -r requirements.txtInstall dependencies
python init_db.pyInitialize database
python app.pyRun the web server
Ctrl+CStop the server
deactivateDeactivate virtual environment

Payloads (Only for this local lab)

PayloadUsername FieldPassword FieldResult
Normaladminsupersecret123Success (fixed)
Comment Outadmin' --anythingBypasses (vulnerable only)
Always True' OR '1'='1' --anythingBypases (vulnerable only)

Key Takeaway

# VULNERABLE
query = f"SELECT ... WHERE username = '{username}' ..."
cur.execute(query)

# SECURE
query = "SELECT ... WHERE username = ? AND password = ?"
cur.execute(query, (username, password))

Rule of thumb: If you ever find yourself using f-strings or string concatenation to build SQL with user input, stop and use parameterized queries instead.