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 PATHbefore 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.
4. Create and open your project with VS Code (Recommended)
- Open Visual Studio Code
- Go to
File > Open Folder - Open the
sqli-labfolder that you just created. - 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) orpython3 --version(macOS/Linux) and see a version number. - I have opened my
sqli-labfolder 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.txthas 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.dbappears 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.pyran successfully and created the database - The server is running on
http://127.0.0.1:5000 - I can login with
admin/supersecret123and 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
testas the username andpassword123as the password, what would the query look like based off ofapp.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.
- Go to the login page.
- For Username, type:
admin' -- - For Password, type:
anything(it doesn’t matter, it will be commented out!) - 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 theAND 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.
- Clear the form (refresh the page)
- For Username, type:
' OR '1'='1' -- - For Password, type:
anything - 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
', addedOR '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!
- Why did
admin' --work, even though we put garbage into the password field? - Why does
'1'='1'make the login succeed? - 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
orin 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?
| Vulnerable | Correct |
|---|---|
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
- Save
app.py - Stop the server if it’s running (
Ctrl+C) and start it again (python app.py) - 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.
| Practice | Why? | Beginner Example |
|---|---|---|
| Hash passwords | If 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 errors | Showing 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 privilege | The 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 limiting | Prevents 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 ORMs | Object 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 MFA | Multi-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 likeerror = "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
--andOR '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.
- Stop the server with
Ctrl+C - Deactivate the virtual environment with
deactivate - 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:
| Resource | What it’s good for |
|---|---|
| OWASP SQL Injection | The industry standard explanation and prevention methods. |
| PortSwigger Web Security Academy | Interactive, in-browser labs that are incredibly well made. Start with their SQLi section! |
| Flask Documentation | To learn more about Flask and expand this application. |
| SQLite Documentation | To learn more about the database that we used. |
Final Challenge (Optional, but recommended)
Now that you know the fix, try to expand this lab yourself!
- Add a
/fixedroute 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
LIKEquery 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
| Term | Definition |
|---|---|
| Blacklist | Trying to block specific words/characters. Not secure against SQLi. |
| Concatenation | Gluing strings together (e.g putting user input directly into a query). The root cause of this vulnerability. |
| Database | A 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. |
| Flask | A Python web framework used to make our website. |
| Injection | When untrusted input is interpreted as code instead of data. |
| Localhost / 127.0.0.1 | Your 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 Statement | Separating the SQL query structure from the data. The secure fix for SQLi. |
| Payload | Malicious input that is crafted to exploit a vulnerability. |
Placeholder (?) | A symbol used in parameterized queries where data will be safely inserted. |
| Query | A 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. |
| String | Text (e.g "admin", "password123"). |
| Terminal / Command Line | A text based interface to run commands on your computer. |
Virtual Environment (venv) | An isolated space to install packages for a specific project. |
| Whitelist | Only 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.pywith the fixed version from chapter 8 after testing!
sqli-lab/requirements.txt
Flask==3.0.3
gunicorn==23.0.0
sqli-lab/.gitignore (Recommended)
__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
| Issue | Possible Cause | Fix |
|---|---|---|
python or py not found | Python 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 scripts | Open 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 installed | Activate your venv ((.venv)) and run pip install -r requirements.txt again. |
| Port 5000 already in use | Something else is running on port 5000 | Close 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 errors | Corrupted or missing database | Delete instance/users.db and run python init_db.py again. |
| Page doesn’t load / connection refused | Server not running | Make sure the terminal with python app.py is still running and hasn’t crashed. Check for errors in that terminal. |
| Changes aren’t showing up | Browser cache or server not restarted | Hard 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 payloads | Smart quotes from some documents | Always type the payloads exactly as shown in the book. Avoid copying from rich text documents, copy from the code blocks. |
Cheat Sheet
Commands
| Command | Purpose |
|---|---|
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.txt | Install dependencies |
python init_db.py | Initialize database |
python app.py | Run the web server |
Ctrl+C | Stop the server |
deactivate | Deactivate virtual environment |
Payloads (Only for this local lab)
| Payload | Username Field | Password Field | Result |
|---|---|---|---|
| Normal | admin | supersecret123 | Success (fixed) |
| Comment Out | admin' -- | anything | Bypasses (vulnerable only) |
| Always True | ' OR '1'='1' -- | anything | Bypases (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.