Hack and Patch a Vulnerable Web App
Build a Flask app with real security flaws, exploit them, then patch each one.
Introduction
30 Second Summary
Every time you hear about a data breach or stolen passwords in the news, a web application vulnerability is usually to blame. Understanding how these attacks work is the first step to preventing them.
In this project, you will build a note-taking web app called SecureNotes, deliberately introduce security flaws, exploit them like a real attacker, then patch every vulnerability using industry-standard defenses.
What You'll Build
A Flask web application with user authentication that you will intentionally build with security flaws mapped to the OWASP Top 10 (2025), exploit, and patch.
By the end of this project, you'll have:
- A working Flask web app with user authentication, note management, and a SQLite database.
- Hands-on experience exploiting SQL Injection, Cross-Site Scripting, and IDOR attacks against your own app.
- A hardened version of the same app with every vulnerability patched using professional security techniques.
- Secret Mission: an automated vulnerability scanner that tests your app for all 4 flaws and generates a security report.
Are there any prerequisites?
You need basic Python knowledge and familiarity with the command line. No prior security experience is required.
Set Up Your Project
Before you can build and attack a vulnerable web app, you need the right tools in place. Flask is a lightweight Python web framework that makes it easy to spin up a web server with just a few lines of code.
In this step, you will install Flask, create your project folder, and set up a database with sample users and notes. This database is the foundation for the SecureNotes app you will build in the next step.
In this step, get ready to:
- Install Flask and verify it is working.
- Create the project folder structure.
- Set up the database with sample users and notes.
Install Flask
Flask requires Python 3.9 or higher. Let's confirm your Python version before installing anything.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
- Check your Python version by running this command:
macOS
python3 --version
This also applies if you are on Linux.
Windows
python --version
✔️ I see version 3.9 or higher
Your Python version is compatible with Flask 3.1.3. You are ready to install Flask.
python vs python3
If you used python3 for the version check, use python3 and pip3 throughout this project wherever you see python and pip.
ⓧ I see an older version
You need Python 3.9 or higher for this project. Download the latest version from the official Python downloads page.
- Visit python.org/downloads.
- Download and run the installer for your operating system.
- On Windows, make sure to check Add Python to PATH during installation.
- On macOS, if the system still resolves to the old version after installing, use the full path or install via pyenv to manage versions.
- After installation, close and reopen your terminal, then run the version check again.
ⓧ Command not found
Python does not appear to be installed or is not in your system PATH. Download and install it from the official Python downloads page.
- Visit python.org/downloads.
- Download the latest Python 3 installer for your operating system.
- On Windows, make sure to check Add Python to PATH during installation.
- On macOS, the installer from python.org adds Python to your PATH automatically.
- After installation, close and reopen your terminal, then run the version check again to confirm it works.
- Install Flask by running this command:
pip install Flask==3.1.3
What does this command do?
pip is Python's package manager. This command downloads and installs Flask version 3.1.3 along with all its dependencies.
- Verify the installation succeeded by running:
python -c "import flask; print(flask.__version__)"
You should see 3.1.3 printed in your terminal.
Seeing an error?
- If you see a "command not found" error for pip, try pip3 install Flask==3.1.3 instead.
- If you used python3 earlier, run python3 -c "import flask; print(flask.__version__)" for the verify step.
- If you see a permissions error, try adding --user to the install command: pip install --user Flask==3.1.3.
help me fix my Flask installation error
Create the project folder structure
Your SecureNotes app needs a home. You will create a project folder with a templates subfolder where Flask looks for HTML files.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir securenotes
cd securenotes
- Create the templates subfolder by running:
mkdir templates
- Confirm your folder structure by running:
ls
You should see templates listed in the output.
Don't see the templates folder?
- Make sure you ran cd securenotes before running mkdir templates. The templates folder must be inside securenotes.
- On Windows, use dir instead of ls to list folder contents.
help me fix my project folder structure
Create the database setup script
Your SecureNotes app needs a database with user accounts and notes to work with. You will create a setup script that builds this database using SQLite, a lightweight database engine built into Python.
- Create a new file called init_db.py inside your securenotes folder (the one you are currently in) and paste the following code into it:
import sqlite3
def init_database():
conn = sqlite3.connect("notes.db")
cursor = conn.cursor()
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL
)
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
)
"""
)
cursor.execute(
"INSERT OR IGNORE INTO users (username, password) VALUES ('alice', 'password123')"
)
cursor.execute(
"INSERT OR IGNORE INTO users (username, password) VALUES ('bob', 'secret456')"
)
cursor.execute("SELECT COUNT(*) FROM notes")
if cursor.fetchone()[0] == 0:
cursor.execute(
"INSERT INTO notes (user_id, title, content) VALUES (1, 'Meeting Notes', 'Discuss Q3 budget with team on Friday')"
)
cursor.execute(
"INSERT INTO notes (user_id, title, content) VALUES (1, 'Shopping List', 'Milk, eggs, bread, coffee')"
)
cursor.execute(
"INSERT INTO notes (user_id, title, content) VALUES (2, 'Confidential', 'API key: sk_live_abc123xyz789')"
)
cursor.execute(
"INSERT INTO notes (user_id, title, content) VALUES (2, 'Passwords', 'Bank: hunter2, Email: letmein')"
)
conn.commit()
conn.close()
print("Database initialized with sample data!")
if __name__ == "__main__":
init_database()
What does this code do?
- sqlite3.connect("notes.db") creates a new SQLite database file called notes.db in your project folder.
- The script creates two tables: users (for login credentials) and notes (for stored notes).
- It inserts two users: alice (password password123) and bob (password secret456).
- It adds four sample notes. Notes 1 and 2 belong to alice. Notes 3 and 4 belong to bob and contain sensitive data like API keys and passwords.
- INSERT OR IGNORE prevents duplicate users if you run the script more than once.
- Save init_db.py.
- Run the script to create your database:
python init_db.py
You should see Database initialized with sample data! printed in your terminal.
- Confirm the database file was created by running:
ls
You should see init_db.py, notes.db, and templates listed in the output.
Seeing an error when running init_db.py?
- If you see a "No such file or directory" error, make sure your terminal is inside the securenotes folder and that init_db.py is saved there.
- If you used python3 earlier, run python3 init_db.py instead.
- If you see a SyntaxError, double-check that you copied the entire code block exactly as shown above.
help me fix my init_db.py error
✔️ Awesome, I've got everything!
Great. Double check you have saved init_db.py and that notes.db now exists in your securenotes folder.
ⓧ I'd like to double check the full code
Here is the complete init_db.py file for comparison:
import sqlite3
def init_database():
conn = sqlite3.connect("notes.db")
cursor = conn.cursor()
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL
)
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
)
"""
)
cursor.execute(
"INSERT OR IGNORE INTO users (username, password) VALUES ('alice', 'password123')"
)
cursor.execute(
"INSERT OR IGNORE INTO users (username, password) VALUES ('bob', 'secret456')"
)
cursor.execute("SELECT COUNT(*) FROM notes")
if cursor.fetchone()[0] == 0:
cursor.execute(
"INSERT INTO notes (user_id, title, content) VALUES (1, 'Meeting Notes', 'Discuss Q3 budget with team on Friday')"
)
cursor.execute(
"INSERT INTO notes (user_id, title, content) VALUES (1, 'Shopping List', 'Milk, eggs, bread, coffee')"
)
cursor.execute(
"INSERT INTO notes (user_id, title, content) VALUES (2, 'Confidential', 'API key: sk_live_abc123xyz789')"
)
cursor.execute(
"INSERT INTO notes (user_id, title, content) VALUES (2, 'Passwords', 'Bank: hunter2, Email: letmein')"
)
conn.commit()
conn.close()
print("Database initialized with sample data!")
if __name__ == "__main__":
init_database()
Your project foundation is in place. Next up, you will build the actual web application on top of this database, complete with intentional security vulnerabilities.
Build the Vulnerable App
Your database is ready with sample users and notes. Now it is time to build the web application itself.
In this step, you will create a Flask app with four intentional security vulnerabilities baked in. These are the same kinds of flaws that appear in real-world applications and lead to data breaches.
In this step, get ready to:
- Create the main application file with deliberate security flaws.
- Build four HTML templates for the app's pages.
- Run the app and verify it works by logging in.
Create app.py with intentional vulnerabilities
The application file handles user login, note display, and note creation. You will write each of these features with a specific security flaw that mirrors real-world vulnerabilities.
- In your text editor, create a new file called app.py inside the securenotes folder.
- Add the imports, app configuration, database helper, and login route by pasting this code:
import sqlite3
from flask import Flask, flash, redirect, render_template, request, session, url_for
app = Flask(__name__)
app.secret_key = 'supersecretkey' # VULNERABILITY: hardcoded secret key
def get_db():
conn = sqlite3.connect('notes.db')
conn.row_factory = sqlite3.Row
return conn
@app.route('/')
def index():
if 'user_id' in session:
return redirect(url_for('notes'))
return redirect(url_for('login'))
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
db = get_db()
# VULNERABILITY: SQL injection via string concatenation
user = db.execute(
f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
).fetchone()
db.close()
if user:
session['user_id'] = user['id']
session['username'] = user['username']
flash('Login successful!', 'success')
return redirect(url_for('notes'))
else:
flash('Invalid credentials!', 'error')
return render_template('login.html')
What does this code do?
This sets up the Flask application and the login route with two deliberate vulnerabilities:
- app.secret_key = 'supersecretkey' is a hardcoded secret. In a real app, an attacker who finds this string in source code can forge session cookies.
- The login query uses an f-string to insert the username and password directly into the SQL. This means an attacker can type special characters to manipulate the query logic.
- Below the login function, add the remaining routes for viewing notes, adding notes, and logging out:
@app.route('/notes')
def notes():
if 'user_id' not in session:
return redirect(url_for('login'))
db = get_db()
user_notes = db.execute(
'SELECT * FROM notes WHERE user_id = ?', (session['user_id'],)
).fetchall()
db.close()
return render_template('notes.html', notes=user_notes, username=session['username'])
@app.route('/note/<int:note_id>')
def view_note(note_id):
if 'user_id' not in session:
return redirect(url_for('login'))
db = get_db()
# VULNERABILITY: IDOR - no ownership check
note = db.execute('SELECT * FROM notes WHERE id = ?', (note_id,)).fetchone()
db.close()
if note is None:
flash('Note not found!', 'error')
return redirect(url_for('notes'))
return render_template('note.html', note=note)
@app.route('/notes/add', methods=['GET', 'POST'])
def add_note():
if 'user_id' not in session:
return redirect(url_for('login'))
if request.method == 'POST':
title = request.form['title']
content = request.form['content']
db = get_db()
db.execute(
'INSERT INTO notes (user_id, title, content) VALUES (?, ?, ?)',
(session['user_id'], title, content)
)
db.commit()
db.close()
flash('Note added!', 'success')
return redirect(url_for('notes'))
return render_template('add_note.html')
@app.route('/logout')
def logout():
session.clear()
flash('Logged out!', 'success')
return redirect(url_for('login'))
if __name__ == '__main__':
app.run(debug=True) # VULNERABILITY: debug mode enabled
What does this code do?
These routes handle the rest of the app's features, with two more vulnerabilities:
- The view_note route fetches any note by ID without checking whether it belongs to the logged-in user. This is an Insecure Direct Object Reference (IDOR) flaw.
- debug=True at the bottom exposes a full interactive debugger to anyone who triggers an error. An attacker can use this to execute arbitrary Python code on your server.
- Save app.py.
Seeing a syntax error when you save?
Check that the f-string line in the login function has matching quotes. The outer quotes are double and the inner values use single quotes. Missing or mismatched quotes are the most common typo here.
help me fix a syntax error in my Flask app.py login function
✔️ Awesome, I've got everything!
Great. Double check you have saved app.py before moving on.
ⓧ I'd like to double check the full code
import sqlite3
from flask import Flask, flash, redirect, render_template, request, session, url_for
app = Flask(__name__)
app.secret_key = 'supersecretkey' # VULNERABILITY: hardcoded secret key
def get_db():
conn = sqlite3.connect('notes.db')
conn.row_factory = sqlite3.Row
return conn
@app.route('/')
def index():
if 'user_id' in session:
return redirect(url_for('notes'))
return redirect(url_for('login'))
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
db = get_db()
# VULNERABILITY: SQL injection via string concatenation
user = db.execute(
f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
).fetchone()
db.close()
if user:
session['user_id'] = user['id']
session['username'] = user['username']
flash('Login successful!', 'success')
return redirect(url_for('notes'))
else:
flash('Invalid credentials!', 'error')
return render_template('login.html')
@app.route('/notes')
def notes():
if 'user_id' not in session:
return redirect(url_for('login'))
db = get_db()
user_notes = db.execute(
'SELECT * FROM notes WHERE user_id = ?', (session['user_id'],)
).fetchall()
db.close()
return render_template('notes.html', notes=user_notes, username=session['username'])
@app.route('/note/<int:note_id>')
def view_note(note_id):
if 'user_id' not in session:
return redirect(url_for('login'))
db = get_db()
# VULNERABILITY: IDOR - no ownership check
note = db.execute('SELECT * FROM notes WHERE id = ?', (note_id,)).fetchone()
db.close()
if note is None:
flash('Note not found!', 'error')
return redirect(url_for('notes'))
return render_template('note.html', note=note)
@app.route('/notes/add', methods=['GET', 'POST'])
def add_note():
if 'user_id' not in session:
return redirect(url_for('login'))
if request.method == 'POST':
title = request.form['title']
content = request.form['content']
db = get_db()
db.execute(
'INSERT INTO notes (user_id, title, content) VALUES (?, ?, ?)',
(session['user_id'], title, content)
)
db.commit()
db.close()
flash('Note added!', 'success')
return redirect(url_for('notes'))
return render_template('add_note.html')
@app.route('/logout')
def logout():
session.clear()
flash('Logged out!', 'success')
return redirect(url_for('login'))
if __name__ == '__main__':
app.run(debug=True) # VULNERABILITY: debug mode enabled
Create the HTML templates
Flask uses Jinja2 templates to render HTML pages. You will create four templates: one for login, one for the notes list, one for viewing a single note, and one for adding notes.
- Create a new file called login.html inside the securenotes/templates folder and paste the following:
<!DOCTYPE html>
<html>
<head>
<title>SecureNotes - Login</title>
<style>
body { font-family: Arial, sans-serif; max-width: 400px; margin: 50px auto; padding: 20px; }
input { display: block; width: 100%; padding: 10px; margin: 10px 0; box-sizing: border-box; }
button { background: #333; color: white; padding: 10px 20px; border: none; cursor: pointer; width: 100%; }
.flash { padding: 10px; margin: 10px 0; border-radius: 4px; }
.success { background: #d4edda; color: #155724; }
.error { background: #f8d7da; color: #721c24; }
</style>
</head>
<body>
<h1>SecureNotes Login</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
{% endwith %}
<form method="POST">
<input type="text" name="username" placeholder="Username" required>
<input type="password" name="password" placeholder="Password" required>
<button type="submit">Login</button>
</form>
</body>
</html>
What does this template do?
This is a simple login form. The get_flashed_messages(with_categories=true) block displays success or error messages from Flask after login attempts. The form submits a POST request to the same route.
- Create notes.html inside securenotes/templates and paste the following:
<!DOCTYPE html>
<html>
<head>
<title>SecureNotes - My Notes</title>
<style>
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.note-card { border: 1px solid #ddd; padding: 15px; margin: 10px 0; border-radius: 4px; }
.note-card h3 { margin: 0 0 5px 0; }
a { color: #333; }
.header { display: flex; justify-content: space-between; align-items: center; }
.btn { background: #333; color: white; padding: 8px 16px; text-decoration: none; border-radius: 4px; }
.flash { padding: 10px; margin: 10px 0; border-radius: 4px; }
.success { background: #d4edda; color: #155724; }
.error { background: #f8d7da; color: #721c24; }
</style>
</head>
<body>
<div class="header">
<h1>My Notes</h1>
<div>
<a href="{{ url_for('add_note') }}" class="btn">+ Add Note</a>
<a href="{{ url_for('logout') }}" class="btn">Logout</a>
</div>
</div>
<p>Welcome, {{ username }}!</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
{% endwith %}
{% for note in notes %}
<div class="note-card">
<h3><a href="{{ url_for('view_note', note_id=note['id']) }}">{{ note['title'] }}</a></h3>
<p>{{ note['content'] | safe }}</p>
</div>
{% endfor %}
{% if not notes %}
<p>No notes yet. Create your first note!</p>
{% endif %}
</body>
</html>
Where is the XSS vulnerability?
Look at the line {{ note['content'] | safe }}. The | safe filter tells Jinja2 to skip HTML escaping. This means if a note contains <script> tags, the browser will execute them as real JavaScript instead of displaying them as harmless text.
- Create note.html inside securenotes/templates and paste the following:
<!DOCTYPE html>
<html>
<head>
<title>SecureNotes - View Note</title>
<style>
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.note-detail { border: 1px solid #ddd; padding: 20px; border-radius: 4px; }
a { color: #333; }
.flash { padding: 10px; margin: 10px 0; border-radius: 4px; }
.success { background: #d4edda; color: #155724; }
.error { background: #f8d7da; color: #721c24; }
</style>
</head>
<body>
<a href="{{ url_for('notes') }}">← Back to Notes</a>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
{% endwith %}
<div class="note-detail">
<h1>{{ note['title'] }}</h1>
<p>{{ note['content'] | safe }}</p>
</div>
</body>
</html>
Same vulnerability here
This template also uses | safe on the note content. Both the list view and the detail view are vulnerable to Cross-Site Scripting attacks.
- Create add_note.html inside securenotes/templates and paste the following:
<!DOCTYPE html>
<html>
<head>
<title>SecureNotes - Add Note</title>
<style>
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
input, textarea { display: block; width: 100%; padding: 10px; margin: 10px 0; box-sizing: border-box; }
textarea { height: 150px; }
button { background: #333; color: white; padding: 10px 20px; border: none; cursor: pointer; }
a { color: #333; }
</style>
</head>
<body>
<a href="{{ url_for('notes') }}">← Back to Notes</a>
<h1>Add New Note</h1>
<form method="POST">
<input type="text" name="title" placeholder="Note title" required>
<textarea name="content" placeholder="Note content" required></textarea>
<button type="submit">Save Note</button>
</form>
</body>
</html>
What does this template do?
This is a simple form for creating new notes. It posts the title and content back to the /notes/add route. There is no vulnerability in this file itself. The XSS flaw only triggers when the saved content is displayed using | safe in the other templates.
- Save all four template files.
- Confirm your templates folder contains login.html, notes.html, note.html, and add_note.html by running:
ls templates
You should see all four HTML files listed.
Missing a template file?
Make sure every file is saved inside the templates subfolder, not in the securenotes root. Flask looks for templates specifically in a folder called templates.
help me check my Flask templates folder structure
Run the app and verify it works
With all the files in place, you can start the Flask development server and confirm the app loads correctly.
- Make sure your terminal is in the securenotes folder, then start the app by running:
python app.py
What should I see?
Flask prints a few lines to the terminal, ending with something like Running on http://127.0.0.1:5000. You will also see a warning about debug mode being enabled. That warning is expected for now.
- Open your web browser.
- Navigate to http://127.0.0.1:5000.
- You should see the SecureNotes login page with a username and password field.
- Enter alice as the username.
- Enter password123 as the password.
- Click Login.
You should see a page titled "My Notes" with a welcome message and alice's two notes (Meeting Notes and Shopping List).
Seeing a "TemplateNotFound" error?
This means Flask cannot locate one of your HTML files. Double check that the templates folder is directly inside securenotes (not nested deeper), and that filenames match exactly: login.html, notes.html, note.html, add_note.html.
help me debug a TemplateNotFound error in my Flask app
Why build a vulnerable app on purpose?
Security professionals call this "offensive security" or "ethical hacking." By writing the insecure code yourself, you understand exactly why each flaw exists. This is far more effective than reading a textbook description of an attack.
In the next step, you will exploit each of these four vulnerabilities like a real attacker would.
Your vulnerable app is live and working. Next up, you will put on your attacker hat and exploit every single one of these flaws.
Attack! Exploit Every Vulnerability
Your vulnerable app is running and ready to be exploited. Now you get to think like an attacker and prove that each vulnerability is real.
You will attempt three different attacks against your own app, each mapped to the OWASP Top 10 (2025). By the end of this step, you will have concrete proof that all four vulnerabilities are exploitable.
In this step, get ready to:
- Bypass authentication entirely using a SQL injection payload.
- Inject and execute malicious JavaScript via Cross-Site Scripting.
- Access another user's confidential data through an IDOR attack.
Bypass authentication with SQL injection
The login form in your app builds its database query by directly inserting whatever the user types into the SQL string. An attacker can exploit this by typing specially crafted input that changes the meaning of the query itself.
What is SQL Injection?
SQL Injection is when an attacker inserts database commands into a form field. Instead of treating the input as data, the app treats it as part of the query logic.
In the OWASP Top 10 (2025), SQL Injection falls under A05:2025 Injection. It is one of the most common and dangerous web vulnerabilities.
- If you are still logged in from the previous step, click **Logout** in the top right corner.
- On the login page, enter the following into the **Username** field:
' OR '1'='1' --
- Enter anything into the **Password** field (for example, anything).
- Click **Login**.
You are now logged in without knowing any valid password. The app shows the notes page with a welcome message.
Why did this work?
Your app.py builds the login query using an f-string:
f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
When you type ' OR '1'='1' -- as the username, the query becomes: SELECT * FROM users WHERE username = '' OR '1'='1' --'. The OR '1'='1' part is always true, so the database returns the first user. The -- comments out the rest of the query, ignoring the password check entirely.
Still seeing the login page?
Make sure you copy the payload exactly, including the single quotes and the space before the two dashes at the end. The payload starts and ends with a single quote character.
Also confirm your app is still running in the terminal. If you closed it, run python app.py again from the securenotes folder.
Help me figure out why my SQL injection attack isn't working
Inject malicious scripts with XSS
Cross-Site Scripting (XSS) happens when an app renders user input as live HTML or JavaScript instead of treating it as plain text. Your templates use the | safe filter, which tells Flask to skip escaping and render content as raw HTML.
- Click **Logout** to return to the login page.
- Log in as alice with username alice and password password123.
- Click **+ Add Note**.
- Enter Test in the **Note title** field.
- Enter the following in the **Note content** field:
<script>alert('XSS!')</script>
- Click **Save Note**.
A browser alert popup saying "XSS!" appears immediately. The JavaScript you typed into the note content field just executed in your browser as if it were part of the app itself.
Why is this dangerous?
An alert box is harmless, but the same technique lets an attacker inject code that steals session cookies, redirects users to fake login pages, or modifies what other users see on the page.
The vulnerability exists because notes.html and note.html use {{ note['content'] | safe }}. The | safe filter tells Jinja2 to render the content as raw HTML instead of escaping it. Without | safe, Flask auto-escapes HTML by default in .html templates.
No alert popup appeared?
Some browsers block certain popup types. Try using Chrome or Edge. Make sure you typed the <script> tags exactly, including the angle brackets.
If you still do not see a popup, click into any note on the notes list. The XSS also fires on the individual note view page.
Help me debug why my XSS attack isn't executing
Access another user's data with IDOR
Insecure Direct Object Reference (IDOR) happens when an app exposes internal object IDs (like database row numbers) in the URL without checking whether the logged-in user actually owns that object.
Your app's note view route fetches any note by its ID. It never checks if the note belongs to the current user.
- Make sure you are still logged in as alice.
- In your browser's address bar, manually change the URL to:
http://127.0.0.1:5000/note/3
You can now see bob's note titled "Confidential" containing his API key. Alice should never have access to this.
- Now change the URL to:
http://127.0.0.1:5000/note/4
You can see bob's "Passwords" note containing his bank and email passwords. By simply guessing sequential note IDs, alice can read every note in the entire database.
Why does this happen?
Look at the view_note function in app.py. The query is SELECT * FROM notes WHERE id = ?. It fetches the note by its ID alone, with no check on user_id. This falls under A01:2025 Broken Access Control, the number one risk in the OWASP Top 10.
Seeing "Note not found" instead?
This means the note IDs in your database are different. If you ran init_db.py multiple times, the auto-increment IDs may have shifted. Try /note/5 or /note/6 instead.
Help me find bob's notes in my database
You have now successfully exploited all four vulnerabilities in your app. You bypassed login with SQL injection, executed JavaScript with XSS, and read another user's private data with IDOR (all while debug mode leaked your app's internals in the background). Next up, you will patch every single one of these flaws using industry-standard defenses.
Defend! Patch Every Vulnerability
You have proven that your app is vulnerable. You exploited SQL Injection to bypass login, XSS to inject scripts, and IDOR to steal another user's data.
Now you switch from attacker to defender. In this step, you will patch every vulnerability using the same techniques professional security engineers apply in production applications.
In this step, get ready to:
- Fix SQL injection and security misconfiguration in app.py.
- Fix Cross-Site Scripting by removing the unsafe template filter.
- Fix the IDOR vulnerability and re-test all three attacks.
Fix SQL injection and security misconfiguration
The two most dangerous flaws in your app right now are the raw SQL query (which lets attackers bypass login) and the hardcoded secret key (which makes session cookies predictable). You will fix both in app.py.
- In app.py, find the imports and secret key at the top of the file:
import sqlite3
from flask import Flask, flash, redirect, render_template, request, session, url_for
app = Flask(__name__)
app.secret_key = 'supersecretkey' # VULNERABILITY: hardcoded secret key
- Replace those lines with the following, which adds import os and generates a random secret key on each server start:
import os
import sqlite3
from flask import Flask, flash, redirect, render_template, request, session, url_for
app = Flask(__name__)
app.secret_key = os.urandom(24)
What does this fix?
A hardcoded secret key means anyone who reads your source code can forge session cookies and impersonate any user.
Using os.urandom(24) generates 24 cryptographically random bytes every time the server starts. An attacker can no longer predict or forge session tokens.
- Now scroll down to the login() function and find the vulnerable query:
user = db.execute(
f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
).fetchone()
- Replace it with a parameterized query that uses ? placeholders:
user = db.execute(
"SELECT * FROM users WHERE username = ? AND password = ?",
(username, password),
).fetchone()
Why do parameterized queries stop SQL injection?
With the f-string version, the database could not tell where your SQL ended and user input began. The attacker's ' OR '1'='1' -- payload became part of the SQL command itself.
With parameterized queries, the database treats ? values as pure data. Even if someone types SQL syntax, it is treated as a literal string, never as executable SQL.
- Finally, scroll to the very bottom of app.py and find:
app.run(debug=True)
- Change True to False so the line now reads:
app.run(debug=False)
Why disable debug mode?
Flask's debug mode exposes an interactive debugger in the browser that allows executing arbitrary Python code. An attacker who triggers an error gets a full Python shell on your server.
- Save app.py.
Fix Cross-Site Scripting
The XSS vulnerability exists because your templates explicitly tell Flask to skip HTML escaping. Flask's Jinja2 engine auto-escapes all output in .html templates by default. The | safe filter overrides that protection.
- Open templates/notes.html and find this line inside the {% for note in notes %} loop:
<p>{{ note['content'] | safe }}</p>
- Remove | safe so the line reads:
<p>{{ note['content'] }}</p>
- Save templates/notes.html.
- Open templates/note.html and find this line inside the note-detail div:
<p>{{ note['content'] | safe }}</p>
- Remove | safe so the line reads:
<p>{{ note['content'] }}</p>
- Save templates/note.html.
How does removing one filter fix XSS?
Jinja2 auto-escaping converts dangerous characters like < into harmless HTML entities like <. The browser renders <script> as visible text instead of executing it as code.
By removing | safe, you let the default auto-escaping do its job. This is called input sanitization.
✔️ Awesome, I've got everything!
Great. Make sure both template files are saved before continuing.
ⓧ I'd like to double check the full code
Here is the complete templates/notes.html:
<!DOCTYPE html>
<html>
<head>
<title>SecureNotes - My Notes</title>
<style>
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.note-card { border: 1px solid #ddd; padding: 15px; margin: 10px 0; border-radius: 4px; }
.note-card h3 { margin: 0 0 5px 0; }
a { color: #333; }
.header { display: flex; justify-content: space-between; align-items: center; }
.btn { background: #333; color: white; padding: 8px 16px; text-decoration: none; border-radius: 4px; }
.flash { padding: 10px; margin: 10px 0; border-radius: 4px; }
.success { background: #d4edda; color: #155724; }
.error { background: #f8d7da; color: #721c24; }
</style>
</head>
<body>
<div class="header">
<h1>My Notes</h1>
<div>
<a href="{{ url_for('add_note') }}" class="btn">+ Add Note</a>
<a href="{{ url_for('logout') }}" class="btn">Logout</a>
</div>
</div>
<p>Welcome, {{ username }}!</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
{% endwith %}
{% for note in notes %}
<div class="note-card">
<h3><a href="{{ url_for('view_note', note_id=note['id']) }}">{{ note['title'] }}</a></h3>
<p>{{ note['content'] }}</p>
</div>
{% endfor %}
{% if not notes %}
<p>No notes yet. Create your first note!</p>
{% endif %}
</body>
</html>
And here is the complete templates/note.html:
<!DOCTYPE html>
<html>
<head>
<title>SecureNotes - View Note</title>
<style>
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.note-detail { border: 1px solid #ddd; padding: 20px; border-radius: 4px; }
a { color: #333; }
.flash { padding: 10px; margin: 10px 0; border-radius: 4px; }
.success { background: #d4edda; color: #155724; }
.error { background: #f8d7da; color: #721c24; }
</style>
</head>
<body>
<a href="{{ url_for('notes') }}">← Back to Notes</a>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
{% endwith %}
<div class="note-detail">
<h1>{{ note['title'] }}</h1>
<p>{{ note['content'] }}</p>
</div>
</body>
</html>
Fix the IDOR vulnerability and re-test all attacks
The final vulnerability is the missing ownership check. Right now, the view_note route fetches any note by ID without verifying it belongs to the logged-in user.
- In app.py, find the query inside the view_note() function:
note = db.execute('SELECT * FROM notes WHERE id = ?', (note_id,)).fetchone()
- Replace it with a query that also checks the note belongs to the current user:
note = db.execute(
"SELECT * FROM notes WHERE id = ? AND user_id = ?",
(note_id, session["user_id"]),
).fetchone()
What does this fix?
Adding AND user_id = ? means the database only returns a note if both conditions are true: the note ID matches AND it belongs to the logged-in user. If alice tries to access bob's note, the query returns nothing and the app shows "Note not found" instead of leaking data.
This is called access control. Every request to a resource must verify that the requester is authorized to see it.
- Save app.py.
✔️ Awesome, I've got everything!
Great. Make sure app.py is saved before restarting the server.
ⓧ I'd like to double check the full code
Here is the complete patched app.py:
import os
import sqlite3
from flask import Flask, flash, redirect, render_template, request, session, url_for
app = Flask(__name__)
app.secret_key = os.urandom(24)
def get_db():
conn = sqlite3.connect("notes.db")
conn.row_factory = sqlite3.Row
return conn
@app.route("/")
def index():
if "user_id" in session:
return redirect(url_for("notes"))
return redirect(url_for("login"))
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form["username"]
password = request.form["password"]
db = get_db()
user = db.execute(
"SELECT * FROM users WHERE username = ? AND password = ?",
(username, password),
).fetchone()
db.close()
if user:
session["user_id"] = user["id"]
session["username"] = user["username"]
flash("Login successful!", "success")
return redirect(url_for("notes"))
else:
flash("Invalid credentials!", "error")
return render_template("login.html")
@app.route("/notes")
def notes():
if "user_id" not in session:
return redirect(url_for("login"))
db = get_db()
user_notes = db.execute(
"SELECT * FROM notes WHERE user_id = ?", (session["user_id"],)
).fetchall()
db.close()
return render_template("notes.html", notes=user_notes, username=session["username"])
@app.route("/note/<int:note_id>")
def view_note(note_id):
if "user_id" not in session:
return redirect(url_for("login"))
db = get_db()
note = db.execute(
"SELECT * FROM notes WHERE id = ? AND user_id = ?",
(note_id, session["user_id"]),
).fetchone()
db.close()
if note is None:
flash("Note not found!", "error")
return redirect(url_for("notes"))
return render_template("note.html", note=note)
@app.route("/notes/add", methods=["GET", "POST"])
def add_note():
if "user_id" not in session:
return redirect(url_for("login"))
if request.method == "POST":
title = request.form["title"]
content = request.form["content"]
db = get_db()
db.execute(
"INSERT INTO notes (user_id, title, content) VALUES (?, ?, ?)",
(session["user_id"], title, content),
)
db.commit()
db.close()
flash("Note added!", "success")
return redirect(url_for("notes"))
return render_template("add_note.html")
@app.route("/logout")
def logout():
session.clear()
flash("Logged out!", "success")
return redirect(url_for("login"))
if __name__ == "__main__":
app.run(debug=False)
All four patches are in place. Time to restart the server and prove the attacks no longer work.
- Switch to the terminal where your app is running.
- Press Ctrl+C to stop the server.
- Restart the app by running:
python app.py
- Re-test SQL Injection: go to http://127.0.0.1:5000 and enter ' OR '1'='1' -- as the username with any password.
- You should see "Invalid credentials!" instead of being logged in.
- Re-test XSS: log in as alice (username: alice, password: password123) and look at the notes list.
- Any XSS notes you added earlier now display as visible text like <script>alert('XSS!')</script> instead of executing.
- Re-test IDOR: while logged in as alice, manually navigate to http://127.0.0.1:5000/note/3.
- You should be redirected back to your notes list with a "Note not found!" message instead of seeing bob's confidential data.
Still seeing vulnerable behavior?
Make sure you saved all three files (app.py, templates/notes.html, templates/note.html) before restarting the server.
If SQL injection still works, check that you replaced the entire f-string line with the parameterized version using ? placeholders and a tuple of values.
If XSS still executes, confirm you removed | safe from both template files (not just one).
Help me debug why my patches aren't working.
Every vulnerability is patched. You just completed a full defensive security pass on your application using parameterized queries, auto-escaping, access control, and secure configuration. Next up, you will take this even further with an automated vulnerability scanner.
Secret mission
Build an Automated Vulnerability Scanner
You've proven you can find and fix vulnerabilities by hand. But what if you could automate the entire process? Build a Python script that tests your app for all 4 security flaws and generates a professional security report.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- Flask dev server running at http://127.0.0.1:5000.
- The securenotes project folder (including app.py, init_db.py, scanner.py, notes.db, and the templates/ folder).
- Python packages: Flask and requests.
Keep everything running
No action needed. Choose this if you are still actively building or want to experiment further with SecureNotes.
- Your Flask server continues running at http://127.0.0.1:5000.
- Your project files remain on your Desktop inside the securenotes folder.
- The Flask and requests packages remain installed in your Python environment.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files stay on disk so you can pick up where you left off.
- Switch to the terminal where your Flask server is running.
- Press Ctrl+C to stop the server.
- To restart later, navigate back to the securenotes folder and run python app.py.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes everything you built in this project.
- Switch to the terminal where your Flask server is running and press Ctrl+C to stop it.
- Delete the project folder by running this command (macOS/Linux):
cd ~/Desktop
rm -rf securenotes
On Windows, run:
cd ~\Desktop
rmdir /s /q securenotes
- Uninstall the Python packages by running:
pip uninstall Flask requests -y
Confirm the folder is gone by running ls ~/Desktop (macOS/Linux) or dir ~\Desktop (Windows). You should no longer see securenotes in the output.
Nice Work!
Nice Work!
You just completed both sides of a real security engagement. You built a web application, broke into it like an attacker, and then locked it down like a defender.
You've learned how to:
- Build a Flask web app from scratch with user authentication and note management, backed by a SQLite database.
- Exploit SQL Injection, Cross-Site Scripting, and IDOR vulnerabilities mapped to the OWASP Top 10 (2025).
- Patch all 4 vulnerabilities using industry-standard defenses: parameterized queries, auto-escaping, access control checks, and secure configuration.
- Built an automated vulnerability scanner that programmatically tests your app for all 4 flaws and generates a security report.
Ready to quiz yourself?