Skip to main content
  1. Languages/
  2. Python Guides/

Python Web Security Checklist: Locking Down Flask and Django Apps (2025 Edition)

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

In the landscape of 2025 web development, security is no longer a specialty—it is a baseline requirement. With the proliferation of AI-assisted hacking tools, automated vulnerability scanners are faster and more ruthless than ever. For Python developers, whether you are building microservices with FastAPI, monolithic apps with Django, or lightweight services with Flask, shipping code without a security audit is negligence.

This article serves as a practical, “quick-fire” checklist and guide to the three most persistent threats in web applications: Cross-Site Scripting (XSS), Cross-Site Request Forgery (CSRF), and SQL Injection. We will move beyond theory and implement robust defenses using modern Python tooling.

Prerequisites and Environment
#

To follow the examples in this guide, ensure you have a modern Python environment set up. By 2025, Python 3.14+ is the standard, but these examples work on 3.12+.

We recommend using a virtual environment.

# Create and activate a virtual environment
python -m venv venv
source venv/bin/activate  # On Windows: venv\Scripts\activate

# Install necessary packages for our examples
pip install flask flask-wtf flask-talisman sqlalchemy bleach

The “Big Three” Vulnerabilities
#

Before we patch them, let’s briefly compare the threats we are addressing. Understanding the vector is key to implementing the correct defense.

Vulnerability Full Name Attack Vector Primary Defense
XSS Cross-Site Scripting Injecting malicious JS into trusted pages. Context-aware output encoding (Escaping) & CSP.
CSRF Cross-Site Request Forgery Tricking a user into executing unwanted actions. Anti-CSRF Tokens (Synchronizer Token Pattern).
SQLi SQL Injection Manipulating database queries via input. Parameterized queries (Prepared Statements) or ORMs.

1. Defeating Cross-Site Scripting (XSS)
#

XSS remains one of the most common vulnerabilities. It occurs when an application includes untrusted data in a web page without proper validation or escaping.

The Vulnerability
#

Imagine a simple Flask search route that reflects the user’s input directly back to the HTML.

# VULNERABLE CODE - DO NOT USE
from flask import Flask, request

app = Flask(__name__)

@app.route('/search')
def search():
    query = request.args.get('query', '')
    # If query is "<script>alert('Hacked')</script>", it runs!
    return f"<h1>Search results for: {query}</h1>"

The Defense: Auto-Escaping and CSP
#

Modern template engines like Jinja2 (used by Flask) and Django Templates auto-escape variables by default. However, vulnerabilities creep in when developers explicitly bypass these protections (e.g., using |safe in Jinja or mark_safe in Django).

Step 1: Always use templates. Step 2: Implement Content Security Policy (CSP).

CSP is an HTTP header that tells the browser which dynamic resources are allowed to load. We can use Flask-Talisman to enforce this.

Secure Implementation
#

# secure_app.py
from flask import Flask, render_template_string
from flask_talisman import Talisman

app = Flask(__name__)

# Enforce HTTPS and Content Security Policy
csp = {
    'default-src': '\'self\'',
    'script-src': '\'self\'', # Block inline scripts and external CDNs not explicitly whitelisted
}
Talisman(app, content_security_policy=csp)

@app.route('/search')
def search():
    query = request.args.get('query', '')
    # Render using Jinja2 engine which auto-escapes by default
    template = "<h1>Search results for: {{ query }}</h1>"
    return render_template_string(template, query=query)

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

Best Practice: Never use string formatting (f-strings) to construct HTML. Always pass variables to the template engine.


2. Preventing Cross-Site Request Forgery (CSRF)
#

CSRF attacks trick a legitimate user into submitting a request they didn’t intend to make. This relies on the browser automatically sending cookies (session IDs) to the target domain.

Visualizing the Attack
#

The following diagram illustrates how a CSRF attack bypasses authentication if tokens are missing.

sequenceDiagram participant Victim as User Browser participant Malicious as Malicious Site participant Bank as Banking App (Vulnerable) Note over Victim, Bank: User is logged into Banking App Victim->>Malicious: Visits attacker's website Malicious->>Victim: Returns page with hidden auto-submit form Note right of Malicious: Form targets Banking App URL Victim->>Bank: POST /transfer (with User's Cookies) Bank->>Bank: Validates Cookies (Success) Bank->>Bank: Executes Transfer (HACKED)

The Defense: Synchronizer Token Pattern
#

The server must generate a unique, random token for the user’s session and render it in every form. When the form is submitted, the server verifies that the token matches.

In the Python ecosystem, Flask-WTF and Django’s Middleware handle this efficiently.

Secure Implementation (Flask)
#

Create a forms.py using Flask-WTF:

from flask import Flask, render_template_string, request
from flask_wtf import FlaskForm, CSRFProtect
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired

app = Flask(__name__)
app.config['SECRET_KEY'] = 's3cr3t-k3y-for-dev-only-2025' # Use env var in production!

# Initialize CSRF Protection globally
csrf = CSRFProtect(app)

class ProfileForm(FlaskForm):
    username = StringField('Username', validators=[DataRequired()])
    submit = SubmitField('Update')

@app.route('/update', methods=['GET', 'POST'])
def update_profile():
    form = ProfileForm()
    if form.validate_on_submit():
        # If we reach here, the CSRF token was valid
        return f"Profile updated for {form.username.data}"
    
    # Simple HTML template with CSRF token
    html = """
    <form method="POST">
        {{ form.hidden_tag() }} <!-- Renders the CSRF token -->
        {{ form.username.label }} {{ form.username() }}
        {{ form.submit() }}
    </form>
    """
    return render_template_string(html, form=form)

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

Note: If you are building a SPA (Single Page Application) with React or Vue and a Python backend, you usually handle CSRF by reading a CSRF-TOKEN cookie and sending it back in a custom HTTP header (e.g., X-CSRFToken).


3. Eliminating SQL Injection (SQLi)
#

In 2025, SQL Injection should be extinct, yet it persists in legacy code and quick scripts. It happens when user input interferes with the SQL query structure.

The Pitfall
#

Using f-strings to build SQL queries is dangerous.

# DANGEROUS - DO NOT USE
user_input = "admin'; --"
query = f"SELECT * FROM users WHERE username = '{user_input}'"
# Resulting Query: SELECT * FROM users WHERE username = 'admin'; --'
# This logs in as admin without a password.

The Defense: ORMs and Parameterization
#

The best defense is to use an ORM (Object-Relational Mapper) like SQLAlchemy (Flask) or the Django ORM. They automatically handle parameter binding.

If you must write raw SQL, use bound parameters.

Secure Implementation (SQLAlchemy)
#

from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session

# Setup in-memory DB
engine = create_engine("sqlite:///:memory:")

def get_user_secure(username_input):
    with Session(engine) as session:
        # METHOD 1: ORM (Preferred)
        # user = session.query(User).filter(User.username == username_input).first()
        
        # METHOD 2: Raw SQL with Parameters (Safe)
        # Notice the :username placeholder
        stmt = text("SELECT * FROM users WHERE username = :username")
        result = session.execute(stmt, {"username": username_input}).all()
        return result

# Even if input contains SQL syntax, it is treated as a literal string.
print(get_user_secure("admin'; DROP TABLE users; --"))

4. Production Security Checklist
#

Beyond code changes, your deployment configuration is the final line of defense. Here is a quick audit list for your pyproject.toml or deployment scripts:

  1. Dependency Scanning: Supply chain attacks are rampant. Use tools to check your requirements.txt.

    pip install pip-audit
    pip-audit
  2. Cookie Security: Ensure your session cookies have the following flags set (usually in Flask/Django config):

    • Secure: Cookies sent only over HTTPS.
    • HttpOnly: JavaScript cannot access the cookie (mitigates XSS token theft).
    • SameSite: Set to ‘Lax’ or ‘Strict’ to mitigate CSRF.
  3. HTTP Headers: Use Strict-Transport-Security (HSTS) to force HTTPS and X-Content-Type-Options: nosniff.

  4. Debug Mode: Never leave DEBUG = True in production. It exposes stack traces, environment variables, and source code snippets to attackers.

Conclusion
#

Web security is a continuous process, not a one-time feature. By systematically addressing XSS with CSP and escaping, neutralizing CSRF with tokens, and preventing SQLi via ORMs, you eliminate the vast majority of attack vectors targeting Python applications.

As we move through 2025, keep your dependencies updated and your vigilance high. A secure application is the foundation of user trust.

Further Reading: