October 28, 2026
I Built a Vulnerable Demo App to Understand How Common Web Bugs Happen
By Zaki · Web Development · Cybersecurity · Python
By Muhummad Zaki
13 min read
I used to think understanding web vulnerabilities meant reading about them, memorizing their names, and looking at a few screenshots from security tools. SQL injection, cross-site scripting, broken access control — I knew what these terms meant, at least on paper.
But there was a problem.
When I opened an actual application and tried to understand why a vulnerability existed, the explanation suddenly felt incomplete. I could recognize the name of a bug without fully understanding the programming decision that caused it.
So I decided to try something different.
I built a small web application, deliberately introduced a few common security mistakes, and then tested what happened when I used it in unexpected ways.
The result was more useful than another afternoon spent reading vulnerability descriptions. I started seeing web security not as a collection of attack names, but as a collection of engineering decisions that can go wrong.
In this article, I'll walk through how I approached the experiment, the bugs I explored, and the Python tools and automation techniques that made the process easier. The examples use a local demo application and intentionally simplified code so you can reproduce the ideas without testing someone else's website.
Why I stopped treating vulnerabilities as definitions
Most introductory security resources explain vulnerabilities in a predictable format:
- What the vulnerability is.
- Why it happens.
- What an attacker might do.
- How to prevent it.
That's useful. But I wanted to understand the connection between the code and the behavior.
For example, saying that SQL injection happens when an application builds database queries unsafely is correct. But I wanted to see exactly what changed when I replaced an unsafe query with a parameterized one.
The same applied to access control. I wanted to know why hiding an administrator button from a regular user doesn't stop that user from requesting the administrator's endpoint directly.
These questions led me to a simple rule:
Don't just study the vulnerability. Build the condition that creates it, observe the result, and then remove the condition.
I chose Python and Flask for the experiment because they let me create a small application without spending most of my time configuring infrastructure.
The goal wasn't to build a production-ready application. It was to build something small enough that I could understand every important line.
1. I started with a deliberately simple application
I didn't begin with a complicated dashboard, payment system, or authentication framework. I built a basic application with a login page, a search endpoint, a profile page, and a small database.
That was enough to explore several important web security concepts.
My local project structure looked something like this:
security-lab/
├── app.py
├── database.py
├── templates/
├── tests/
└── requirements.txtsecurity-lab/
├── app.py
├── database.py
├── templates/
├── tests/
└── requirements.txtI used Flask for routing, SQLite for the database, and Python's built-in testing tools to automate checks.
The first version was intentionally simple. I wanted fewer moving parts, because debugging a security issue is difficult enough without wondering whether a complicated framework configuration is responsible.
I also kept the application isolated on my own machine. A deliberately vulnerable application should be treated like an experiment, not exposed to the public internet.
One important decision was to keep two versions of the relevant code: a vulnerable implementation for comparison and a corrected implementation for verification.
That gave me a way to answer a more useful question than "Can this bug happen?"
I could ask, "What exact change prevents it, and does the fix continue to work when I test it automatically?"
2. SQL injection taught me why string concatenation is dangerous
SQL injection was one of the first vulnerabilities I wanted to understand properly.
Consider a search feature. A user submits a name, and the application looks for matching records in a database.
The problem begins when developers treat user input as part of the SQL statement itself.
Here's a deliberately unsafe example:
query = f"SELECT * FROM users WHERE name = '{name}'"query = f"SELECT * FROM users WHERE name = '{name}'"This looks harmless when name contains an ordinary value such as Alice.
But the application is combining two different things — SQL instructions and user-provided data — into a single string. That means specially crafted input can potentially change how the database interprets the query.
The important lesson isn't that certain characters are dangerous. It's that the application has failed to maintain a reliable boundary between code and data.
I found this distinction useful because it explains why simply removing a few suspicious characters isn't a dependable solution.
Attackers can often find alternative inputs, and character filters are easy to get wrong.
The fix is smaller than the explanation
For SQLite, parameterized queries provide a much safer way to pass user input:
cursor.execute( "SELECT * FROM users WHERE name = ?", (name,))cursor.execute( "SELECT * FROM users WHERE name = ?", (name,))The SQL statement and its parameter are supplied separately. SQLite treats the supplied value as data rather than interpreting it as part of the SQL syntax.
That doesn't make every database operation automatically secure, but it addresses this specific query-construction problem.
I also learned to distinguish SQL injection from ordinary input validation. Validation asks whether a value meets the application's requirements. Parameterization protects the SQL query structure.
They're related security practices, but they solve different problems.
What I took away: When reviewing database code, I now pay attention to how a query is constructed before worrying about whether the input looks suspicious.
3. Cross-site scripting made me think differently about output
The next experiment involved cross-site scripting, commonly called XSS.
The basic idea is that an application includes untrusted content in a web page in a way that allows the browser to interpret it as executable code.
Imagine a comment feature where users can submit messages that appear on a page.
A dangerous implementation might insert the submitted message directly into an HTML response without appropriate escaping.
return f"<p>{comment}</p>"return f"<p>{comment}</p>"The code is short, readable, and potentially unsafe.
The problem is that the browser doesn't know which parts of the resulting HTML were intended as markup and which parts were meant to be ordinary user content.
If untrusted content is interpreted as HTML or JavaScript in the wrong context, it can create a cross-site scripting vulnerability.
Why the output context matters
My main lesson was that security doesn't end when input reaches the server.
Developers often focus on what users submit. But applications also need to consider what happens when that information is displayed.
For example:
- A comment displayed as plain text needs appropriate HTML escaping.
- A value inserted into a JavaScript context requires different precautions.
- A URL placed in a link needs validation appropriate to its intended use.
- A template that renders untrusted HTML deliberately requires special care.
When using Flask with Jinja templates, I prefer normal template rendering because Jinja automatically escapes many values in standard HTML contexts by default.
For example:
return render_template( "comment.html", comment=comment)return render_template( "comment.html", comment=comment)The template can then render the value using ordinary variable interpolation:
<p>{{ comment }}</p><p>{{ comment }}</p>This is safer than constructing HTML with string concatenation, provided autoescaping is enabled and the value isn't deliberately marked safe.
That qualification matters. Template engines can't protect every context automatically, and developers can disable their protections.
I also learned not to treat Content Security Policy as a replacement for proper output handling. A well-configured policy can provide an additional layer of defense, but it shouldn't be the primary fix for unsafe rendering.
What I took away: Whenever an application displays user-controlled content, I ask how the browser will interpret it — not just whether the server accepted it.
4. Broken access control showed me that hidden buttons aren't security
This was one of the most useful parts of the experiment.
Suppose an application has a profile page. Every user can view their own profile, but only administrators should be able to view administrative information.
A common mistake is to hide the administrator link from ordinary users and assume the problem is solved.
It isn't.
The browser controls what the user sees. The server must control what the user is allowed to do.
Imagine a route that retrieves a profile using an identifier supplied in the URL:
@app.get("/users/<int:user_id>")def profile(user_id): return get_user_profile(user_id)@app.get("/users/<int:user_id>")def profile(user_id): return get_user_profile(user_id)This route doesn't show any authorization check. Depending on the surrounding application, an authenticated user might be able to request another user's profile simply by changing the identifier.
That can lead to insecure direct object reference issues, commonly associated with broken access control.
The correct solution isn't to make the URL harder to guess.
It's to verify that the current user has permission to access the requested object.
For example, an application might scope the database lookup to the authenticated user:
profile = get_profile_for_user( current_user.id, user_id)profile = get_profile_for_user( current_user.id, user_id)The function would need to enforce the application's ownership and authorization rules, returning an appropriate response when access isn't permitted.
Administrative actions need their own server-side permission checks. Being logged in is not the same as being an administrator.
The detail I initially underestimated
Authentication and authorization answer two different questions.
- Authentication: Who are you?
- Authorization: What are you allowed to do?
An application can correctly identify a user and still allow that user to access data they shouldn't see.
This is why I wouldn't rely on frontend checks, hidden navigation links, or unpredictable identifiers as security controls.
What I took away: Every sensitive endpoint needs a clear answer to the question, "Why is this particular user allowed to perform this particular action?"
5. Weak session handling made me examine what happens after login
A login form is only one part of authentication.
Once a user successfully signs in, the application needs a reliable way to maintain that authenticated state. This is where sessions and cookies become important.
I explored several questions:
- Can an unauthenticated visitor access a protected route?
- Does logging out invalidate the application's authentication state?
- Are session cookies configured appropriately?
- Does the application change session state safely after login?
- Can authorization checks be bypassed by manipulating client-controlled values?
One mistake I wanted to avoid was assuming that a value stored in the browser must be trustworthy.
A client can inspect and modify many things sent to a server. The application must verify security-sensitive information rather than blindly trusting it.
For a Flask application, I would start by checking that the secret key is generated and stored securely, session cookies use appropriate settings, and production traffic uses HTTPS.
For example, production cookie settings can include:
app.config.update( SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SECURE=True, SESSION_COOKIE_SAMESITE="Lax")app.config.update( SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SECURE=True, SESSION_COOKIE_SAMESITE="Lax")HttpOnly prevents ordinary JavaScript from reading the session cookie. Secure restricts cookie transmission to HTTPS, and SameSite helps limit certain cross-site cookie-sending scenarios.
These settings don't replace proper authentication, authorization, or CSRF protection. They address particular risks around browser cookie behavior.
I also made sure not to put passwords, API keys, or other sensitive information directly into client-controlled session data.
The broader lesson was that a login page can look completely correct while the session management behind it remains weak.
6. I added automated tests instead of relying on manual checks
After exploring the vulnerabilities individually, I wanted a repeatable way to check whether the fixes continued to work.
Manually opening pages and clicking around is useful during development, but it's a poor long-term security testing strategy.
It's easy to forget a step. It's also easy to fix one bug and accidentally reintroduce it during a later change.
Python's pytest framework and Flask's test client made this process much easier.
I could write tests for expected application behavior and run them whenever I changed the code.
For example, a basic test could verify that a protected endpoint rejects unauthenticated requests:
def test_private_page_requires_login(client): response = client.get("/private") assert response.status_code in (302, 401)def test_private_page_requires_login(client): response = client.get("/private") assert response.status_code in (302, 401)The exact expected status depends on how the application handles authentication. A redirect to the login page and an HTTP 401 response can both be reasonable designs.
I would also add tests for access control, input handling, and the expected behavior of database queries.
For example, a test for object-level authorization should check that one user cannot retrieve another user's private information. A test for a comment feature should verify that untrusted text is rendered safely rather than interpreted as executable markup.
The most important part is to test the security property, not just whether the page loads.
I started thinking in terms of security regression tests
A regression test captures a behavior that must remain correct as the application changes.
For a security-focused project, that means tests such as:
- Anonymous users cannot access protected routes.
- Ordinary users cannot perform administrator-only actions.
- Users cannot retrieve another user's private records
- Database queries treat user input as data.
- User-submitted comments are rendered safely.
- Invalid or unexpected input doesn't expose sensitive information.
These tests don't prove an application is secure. They establish that specific protections continue to behave as expected.
That's still a substantial improvement over testing once, declaring the bug fixed, and moving on.
7. I automated the boring checks with Python
This is where the experiment became especially useful for my everyday development workflow.
I didn't want to repeat the same commands manually after every small change. I wanted a single command that would run the checks and tell me whether something had broken.
For a small Python project, a simple shell script or task runner can be enough.
For example:
python -m pytest -qpython -m pytest -qThat runs the project's test suite quietly.
I could expand the workflow to include linting, dependency checks, and other local verification steps. As the project grows, these checks can be integrated into a continuous integration pipeline so they run automatically when code changes.
For dependency auditing, I explored tools such as pip-audit, which checks installed Python dependencies against known vulnerability information.
The workflow could look like this:
python -m pytest
python -m pip_auditpython -m pytest
python -m pip_auditThese commands serve different purposes. The tests check application behavior, while the dependency audit looks for known vulnerabilities in packages.
Neither tool can determine whether every authorization check is correct or whether every input is handled safely. They complement code review and targeted security tests rather than replacing them.
I also learned that automation becomes more useful when it produces actionable results. A report that says "security check failed" is less helpful than one that identifies the affected test, endpoint, or dependency.
My preference is to start with a few reliable checks, make them easy to run, and add more as the application evolves.
There's little value in creating a sophisticated security pipeline that nobody understands or maintains.
8. I learned to separate vulnerability discovery from vulnerability prevention
One thing became clear as I worked through the examples: finding a problem and preventing it from returning are two separate tasks.
A vulnerability scanner might identify a suspicious endpoint. A test might demonstrate that an unauthenticated request receives an unexpected response. A code review might reveal an unsafe SQL query.
Each approach provides different evidence.
I started thinking about a simple workflow:
- Identify the risky behavior
Find the route, query, template, or permission check that might be responsible.
- Reproduce it locally
Use controlled test inputs against the demo application and confirm the behavior.
- Fix the underlying cause
Use parameterized queries, safe output handling, or explicit authorization checks as appropriate.
- Add a regression test
Verify the intended protection and run the test again after future changes.
This process helped me connect security concepts to ordinary software engineering.
A vulnerability isn't always an exotic technical failure. Sometimes it's an assumption that was never checked, a permission rule that was implemented only in the interface, or a database query that was built in the wrong way.
The best fix addresses that underlying cause.
Pro tip: When you discover a security bug, don't stop when you can reproduce it. Write a test that fails before the fix and passes after it. That test becomes a small, permanent reminder of a mistake your application should never make again.
9. The tools I would use to extend the project
Once the basic application and tests were working, I had several options for taking the experiment further.
[Security Series] Website Vulnerability Testing using OWASP ZAP | by Rahmat Wibowo | Medium
OWASP ZAP
Dynamic application testing
A useful tool for exploring HTTP requests and responses and identifying potential web application security issues. I would run it against the local demo app and investigate findings manually.
Everyone Needs an HTTP Proxy for Debugging — Huli's blog
Burp Suite
Request inspection and testing
Helpful for understanding how browser requests, cookies, parameters, and server responses interact. The proxy makes it easier to examine the actual traffic behind a feature.
Testning | Programmering i Python
pytest
Repeatable verification
My preferred starting point for turning individual security expectations into tests that can be rerun after every change.
pip-audit
Dependency auditing
Useful for checking whether Python packages in the environment have known reported vulnerabilities. It doesn't replace application-level security testing.
I wouldn't run every tool simply because it appears on a security checklist. I'd select tools based on the question I'm trying to answer.
If I want to understand how a request reaches a route, I inspect the request. If I want to check whether a user can access another user's record, I write an authorization test. If I want to identify vulnerable dependencies, I audit the dependency environment.
The tool should follow the question, not the other way around.
10. What I would do differently if I started again
Looking back at this kind of project, I would keep the scope small for longer.
It's tempting to build a complete authentication system, add several user roles, create an administrator dashboard, and introduce every vulnerability from a security checklist.
But the more features you add, the harder it becomes to understand which component is responsible for a particular behavior.
I'd start with one vulnerability and one minimal route. I'd reproduce the issue, document the cause, implement the fix, and add a test before moving on.
I'd also maintain a short record for every experiment:
Question
What to record
What was wrong?
The unsafe coding pattern
Why did it matter?
The security property that failed
How did I reproduce it?
A local request or automated test
What fixed it?
The code or configuration change
How did I verify the fix?
A test demonstrating the expected behavior
This makes the project useful as a learning resource instead of a collection of disconnected demonstrations.
I'd also be careful about the boundaries of the lab. The application would use synthetic data, local-only access, and no real credentials. I would never point the tests at a public website without explicit authorization.
A local lab is valuable precisely because it gives you room to experiment without putting someone else's data or infrastructure at risk.
What changed in how I think about web security?
The biggest benefit wasn't learning the names of more vulnerabilities. It was learning to connect those vulnerabilities to the code that creates them.
SQL injection became easier to understand when I examined the boundary between SQL instructions and user input.
Cross-site scripting made more sense when I looked at how browsers interpret rendered content.
Broken access control became obvious when I stopped confusing frontend visibility with server-side authorization.
Session security became less mysterious when I examined the information exchanged between the browser and server.
And automated testing gave me a way to verify that the corrections continued to work.
These are all familiar web development topics. But examining them in a controlled application changed how I approached them.
I now see security as part of the development process rather than something added after a feature is finished.
My advice is simple: build a small application, introduce one mistake at a time, observe the behavior, fix the cause, and automate the verification. You don't need an elaborate lab to begin. A few routes, a local database, and a handful of meaningful tests can teach you a great deal.
If you're learning Python, web development, or application security, try building your own local security lab. Start with SQL injection or broken access control, then expand only when you understand the first example well.
I'd be interested to hear which vulnerability you found hardest to understand before you built a working example. Was it SQL injection, XSS, session handling, or access control?
Share your experience in the comments.