Skip to main content

Flask CSRF Protection with Flask-WTF

Flask CSRF Protection with Flask-WTF

Cross-Site Request Forgery (CSRF) is a type of attack where an attacker tricks a user into submitting a malicious request to a web application on which the user is authenticated. Flask-WTF, a Flask extension, provides built-in CSRF protection to secure forms against such attacks, making it essential for safe web applications, especially in data-driven contexts like dashboards or machine learning (ML) interfaces using Pandas. This guide covers Flask CSRF protection, including setup, implementation, customization, best practices, and practical examples, with a focus on secure form handling.


01. Overview of CSRF and Flask-WTF

CSRF attacks exploit a user’s authenticated session to execute unauthorized actions. Flask-WTF mitigates this by generating and validating a CSRF token for each form submission, ensuring requests originate from the legitimate user. This is critical for protecting sensitive operations like form submissions or file uploads in Flask applications.

  • Purpose: Prevent unauthorized form submissions by verifying request authenticity.
  • Key Components: SECRET_KEY, CSRF token, form.hidden_tag(), and FlaskForm.
  • Use Cases: Securing ML input forms, Pandas data uploads, or user registration forms.

1.1 How CSRF Protection Works

  • A unique CSRF token is generated for each user session and included in forms via a hidden field.
  • On form submission, Flask-WTF validates the token to ensure the request is legitimate.
  • Invalid or missing tokens result in a 400 Bad Request error or custom handling.

02. Setting Up Flask-WTF for CSRF Protection

2.1 Installation

Install Flask-WTF using pip:

pip install flask-wtf

2.2 Project Structure

project/
├── app.py
├── static/
│   ├── css/
│   │   └── style.css
├── templates/
│   ├── base.html
│   ├── form.html
│   └── result.html
└── uploads/

2.3 Basic Configuration

Flask-WTF requires a SECRET_KEY to generate secure CSRF tokens and manage sessions.

Example: Basic CSRF-Protected Form

File: app.py

from flask import Flask, render_template
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired

app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key'  # Required for CSRF

class NameForm(FlaskForm):
    name = StringField('Name', validators=[DataRequired()])
    submit = SubmitField('Submit')

@app.route('/form', methods=['GET', 'POST'])
def form():
    form = NameForm()
    if form.validate_on_submit():
        name = form.name.data
        return render_template('result.html', name=name)
    return render_template('form.html', form=form)

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

File: templates/base.html

<!DOCTYPE html>
<html>
<head>
    <title>{% block title %}Flask CSRF Protection{% endblock %}</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
    <div class="container mt-3">
        {% block content %}{% endblock %}
    </div>
</body>
</html>

File: static/css/style.css

body {
    font-family: Arial, sans-serif;
}
.form-group {
    margin-bottom: 15px;
}
.error {
    color: red;
    font-size: 0.9em;
}

File: templates/form.html

{% extends 'base.html' %}

{% block title %}Name Form{% endblock %}

{% block content %}
    <h1>Enter Your Name</h1>
    <form method="post" novalidate>
        {{ form.hidden_tag() }}
        <div class="form-group">
            {{ form.name.label }}
            {{ form.name(class="form-control") }}
            {% for error in form.name.errors %}
                <span class="error">{{ error | escape }}</span>
            {% endfor %}
        </div>
        {{ form.submit(class="btn btn-primary") }}
    </form>
{% endblock %}

File: templates/result.html

{% extends 'base.html' %}

{% block title %}Result{% endblock %}

{% block content %}
    <h1>Hello, {{ name | title | escape }}!</h1>
    <a href="{{ url_for('form') }}" class="btn btn-secondary">Back</a>
{% endblock %}

Output (/form):

  • GET: Displays a Bootstrap-styled form with a CSRF token.
  • POST (valid name): Redirects to a result page with the name.
  • POST (missing CSRF token): Raises a 400 Bad Request error.

Explanation:

  • SECRET_KEY: Secures CSRF tokens and sessions.
  • form.hidden_tag(): Adds a hidden input with the CSRF token.
  • validate_on_submit(): Validates the CSRF token and form data.

03. Implementing CSRF Protection

3.1 Key Steps

  • Set SECRET_KEY: Configure a strong, unique key in app.config.
  • Use FlaskForm: Define forms with FlaskForm to enable CSRF.
  • Include CSRF Token: Add {{ form.hidden_tag() }} in templates.
  • Handle POST Requests: Use methods=['GET', 'POST'] and validate with form.validate_on_submit().

3.2 CSRF Token in Forms

The CSRF token is automatically generated and included as a hidden field when using form.hidden_tag(). For example:

<form method="post">
    {{ form.hidden_tag() }}
    <!-- Renders: <input type="hidden" name="csrf_token" value="generated_token"> -->
    <!-- Other form fields -->
</form>

3.3 Example with File Upload

CSRF protection is critical for file uploads, which are common in data-driven applications.

Example: CSRF-Protected File Upload

File: app.py

from flask import Flask, render_template
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed, FileRequired
from wtforms import SubmitField
import pandas as pd
import os

app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key'
app.config['UPLOAD_FOLDER'] = 'uploads'

class UploadForm(FlaskForm):
    file = FileField('CSV File', validators=[FileRequired(), FileAllowed(['csv'], 'CSV files only')])
    submit = SubmitField('Upload')

@app.route('/upload', methods=['GET', 'POST'])
def upload():
    form = UploadForm()
    if form.validate_on_submit():
        file = form.file.data
        file_path = os.path.join(app.config['UPLOAD_FOLDER'], file.filename)
        file.save(file_path)
        df = pd.read_csv(file_path)
        return render_template('upload.html', form=form, data=df.to_dict(orient='records'))
    return render_template('upload.html', form=form)

if __name__ == '__main__':
    os.makedirs('uploads', exist_ok=True)
    app.run(debug=True)

File: templates/upload.html

{% extends 'base.html' %}

{% block title %}Upload CSV{% endblock %}

{% block content %}
    <h1>Upload CSV File</h1>
    <form method="post" enctype="multipart/form-data" novalidate>
        {{ form.hidden_tag() }}
        <div class="form-group">
            {{ form.file.label }}
            {{ form.file(class="form-control") }}
            {% for error in form.file.errors %}
                <span class="error">{{ error | escape }}</span>
            {% endfor %}
        </div>
        {{ form.submit(class="btn btn-primary") }}
    </form>
    {% if data %}
        <h2>Data Preview</h2>
        <table class="table table-striped mt-3">
            <thead>
                <tr>
                    {% for key in data[0].keys() %}
                        <th>{{ key | title }}</th>
                    {% endfor %}
                </tr>
            </thead>
            <tbody>
                {% for row in data %}
                    <tr>
                        {% for value in row.values() %}
                            <td>{{ value | escape }}</td>
                        {% endfor %}
                    </tr>
                {% endfor %}
            </tbody>
        </table>
    {% endif %}
{% endblock %}

Output (/upload):

  • GET: Displays a file upload form with a CSRF token.
  • POST (valid CSV): Saves and displays the CSV data in a table.
  • POST (missing CSRF token): Raises a 400 Bad Request error.

Explanation:

  • enctype="multipart/form-data": Required for file uploads.
  • form.hidden_tag(): Ensures CSRF protection for file submissions.
  • Pandas: Processes the uploaded CSV for display.

04. Customizing CSRF Protection

4.1 Disabling CSRF for Specific Forms

While not recommended, CSRF protection can be disabled for specific forms by setting meta = {'csrf': False} in the form class. Use this cautiously, only for forms not requiring security (e.g., non-sensitive GET forms).

Example: Disabling CSRF Protection

File: app.py

from flask import Flask, render_template
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField

app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key'

class NoCsrfForm(FlaskForm):
    query = StringField('Search Query')
    submit = SubmitField('Search')
    meta = {'csrf': False}  # Disable CSRF

@app.route('/search', methods=['GET', 'POST'])
def search():
    form = NoCsrfForm()
    if form.validate_on_submit():
        query = form.query.data
        return render_template('result.html', query=query)
    return render_template('search.html', form=form)

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

File: templates/search.html

{% extends 'base.html' %}

{% block title %}Search{% endblock %}

{% block content %}
    <h1>Search</h1>
    <form method="post" novalidate>
        <div class="form-group">
            {{ form.query.label }}
            {{ form.query(class="form-control") }}
        </div>
        {{ form.submit(class="btn btn-primary") }}
    </form>
{% endblock %}

Output (/search):

Form submits without a CSRF token, but this increases vulnerability.

Warning: Disabling CSRF protection exposes forms to attacks. Use only for non-sensitive operations.

4.2 Custom CSRF Error Handling

By default, invalid CSRF tokens raise a 400 Bad Request error. You can customize this behavior by handling the CSRFError exception.

Example: Custom CSRF Error Handling

File: app.py

from flask import Flask, render_template
from flask_wtf import FlaskForm
from flask_wtf.csrf import CSRFError
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired

app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key'

class NameForm(FlaskForm):
    name = StringField('Name', validators=[DataRequired()])
    submit = SubmitField('Submit')

@app.errorhandler(CSRFError)
def handle_csrf_error(e):
    return render_template('csrf_error.html', reason=e.description), 400

@app.route('/form', methods=['GET', 'POST'])
def form():
    form = NameForm()
    if form.validate_on_submit():
        name = form.name.data
        return render_template('result.html', name=name)
    return render_template('form.html', form=form)

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

File: templates/csrf_error.html

{% extends 'base.html' %}

{% block title %}CSRF Error{% endblock %}

{% block content %}
    <h1>CSRF Error</h1>
    <p>{{ reason | escape }}</p>
    <a href="{{ url_for('form') }}" class="btn btn-secondary">Try Again</a>
{% endblock %}

Output (invalid CSRF token):

A custom error page with the reason (e.g., "CSRF token missing").

Explanation:

  • @app.errorhandler(CSRFError): Catches CSRF errors.
  • Custom template: Provides user-friendly feedback.

05. CSRF Protection in Data-Driven Applications

CSRF protection is vital for securing forms in data-driven applications, such as ML input forms or data uploads.

Example: CSRF-Protected ML Prediction Form

File: app.py

from flask import Flask, render_template
from flask_wtf import FlaskForm
from flask_wtf.csrf import CSRFError
from wtforms import FloatField, SubmitField
from wtforms.validators import DataRequired, NumberRange
import pandas as pd

app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key'

@app.template_filter('probability')
def probability_filter(value):
    try:
        return f"{float(value) * 100:.1f}%"
    except (ValueError, TypeError):
        return value

@app.errorhandler(CSRFError)
def handle_csrf_error(e):
    return render_template('csrf_error.html', reason=e.description), 400

class PredictionForm(FlaskForm):
    feature1 = FloatField('Feature 1', validators=[DataRequired(), NumberRange(min=0, max=1)])
    feature2 = FloatField('Feature 2', validators=[DataRequired(), NumberRange(min=0, max=1)])
    submit = SubmitField('Predict')

@app.route('/dashboard', methods=['GET', 'POST'])
def dashboard():
    form = PredictionForm()
    df = pd.DataFrame({
        'Name': ['Alice', 'Bob', 'Charlie'],
        'Age': [25, 30, 35],
        'Salary': [50000, 60000, 55000]
    })
    prediction = None
    if form.validate_on_submit():
        feature1 = form.feature1.data
        feature2 = form.feature2.data
        prediction = (feature1 + feature2) / 2  # Mock ML prediction
    return render_template('dashboard.html', form=form, data=df.to_dict(orient='records'), prediction=prediction)

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

File: templates/dashboard.html

{% extends 'base.html' %}

{% block title %}ML Dashboard{% endblock %}

{% block content %}
    <h1>ML Prediction Dashboard</h1>
    <table class="table table-striped">
        <thead>
            <tr>
                <th>Name</th>
                <th>Age</th>
                <th>Salary</th>
            </tr>
        </thead>
        <tbody>
            {% for row in data %}
                <tr>
                    <td>{{ row.Name | title }}</td>
                    <td>{{ row.Age }}</td>
                    <td>${{ row.Salary | float | round(2) }}</td>
                </tr>
            {% endfor %}
        </tbody>
    </table>
    <h2>Make a Prediction</h2>
    <form method="post" novalidate>
        {{ form.hidden_tag() }}
        <div class="form-group">
            {{ form.feature1.label }}
            {{ form.feature1(class="form-control") }}
            {% for error in form.feature1.errors %}
                <span class="error">{{ error | escape }}</span>
            {% endfor %}
        </div>
        <div class="form-group">
            {{ form.feature2.label }}
            {{ form.feature2(class="form-control") }}
            {% for error in form.feature2.errors %}
                <span class="error">{{ error | escape }}</span>
            {% endfor %}
        </div>
        {{ form.submit(class="btn btn-primary") }}
    </form>
    {% if prediction is not none %}
        <p>Prediction Probability: {{ prediction | probability }}</p>
    {% endif %}
{% endblock %}

File: templates/csrf_error.html

{% extends 'base.html' %}

{% block title %}CSRF Error{% endblock %}

{% block content %}
    <h1>CSRF Error</h1>
    <p>{{ reason | escape }}</p>
    <a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Try Again</a>
{% endblock %}

Output (/dashboard):

  • GET: Displays a Pandas DataFrame table and a prediction form with a CSRF token.
  • POST (valid): Shows the table and a prediction (e.g., Prediction Probability: 70.0%).
  • POST (invalid CSRF): Shows a custom CSRF error page.

Explanation:

  • form.hidden_tag(): Secures the ML input form.
  • CSRFError handler: Provides user-friendly error feedback.
  • Pandas: Displays a dataset alongside the form.

06. CSRF Protection for AJAX Requests

For AJAX-based forms (e.g., using JavaScript), the CSRF token must be included in the request headers or payload.

Example: CSRF Protection with AJAX

File: app.py

from flask import Flask, render_template, jsonify
from flask_wtf.csrf import CSRFProtect
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired

app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key'
csrf = CSRFProtect(app)  # Enable CSRF for non-form routes

class AjaxForm(FlaskForm):
    name = StringField('Name', validators=[DataRequired()])
    submit = SubmitField('Submit')

@app.route('/ajax', methods=['GET', 'POST'])
def ajax():
    form = AjaxForm()
    if form.validate_on_submit():
        return jsonify({'name': form.name.data})
    return render_template('ajax.html', form=form)

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

File: templates/ajax.html

{% extends 'base.html' %}

{% block title %}AJAX Form{% endblock %}

{% block content %}
    <h1>AJAX Form</h1>
    <form id="ajaxForm" method="post" novalidate>
        {{ form.hidden_tag() }}
        <div class="form-group">
            {{ form.name.label }}
            {{ form.name(class="form-control") }}
            {% for error in form.name.errors %}
                <span class="error">{{ error | escape }}</span>
            {% endfor %}
        </div>
        {{ form.submit(class="btn btn-primary") }}
    </form>
    <div id="result"></div>

    <script>
        document.getElementById('ajaxForm').addEventListener('submit', async (e) => {
            e.preventDefault();
            const form = e.target;
            const formData = new FormData(form);
            const csrfToken = form.querySelector('[name=csrf_token]').value;

            try {
                const response = await fetch('/ajax', {
                    method: 'POST',
                    headers: {
                        'X-CSRF-Token': csrfToken
                    },
                    body: formData
                });
                const data = await response.json();
                document.getElementById('result').innerText = `Hello, ${data.name}!`;
            } catch (error) {
                document.getElementById('result').innerText = 'Error occurred';
            }
        });
    </script>
{% endblock %}

Output (/ajax):

  • GET: Displays a form.
  • POST (valid AJAX): Updates the page with the submitted name.
  • POST (invalid CSRF): Fails with a CSRF error.

Explanation:

  • CSRFProtect(app): Enables CSRF for non-form routes.
  • X-CSRF-Token: Sends the CSRF token in the AJAX request header.

07. Best Practices for CSRF Protection

7.1 Recommended Practices

  • Use Strong SECRET_KEY: Generate a random, unpredictable key and keep it secure.
  • Always Include form.hidden_tag(): Ensure every POST form has a CSRF token.
  • Handle CSRF Errors: Use @app.errorhandler(CSRFError) for user-friendly feedback.
  • Secure AJAX Requests: Include CSRF tokens in headers or payloads for AJAX forms.
  • Validate Forms: Combine CSRF protection with field validators for comprehensive security.

7.2 Security Considerations

  • Protect All POST Routes: Ensure all POST endpoints use CSRF protection unless explicitly safe.
  • Sanitize Inputs: Use libraries like bleach alongside CSRF protection to prevent XSS.
  • Secure File Uploads: Validate file types and sizes with CSRF protection.

Example: Insecure Form Without CSRF

File: app.py

from flask import Flask, render_template, request

app = Flask(__name__)

@app.route('/insecure', methods=['GET', 'POST'])
def insecure():
    if request.method == 'POST':
        name = request.form.get('name')
        return render_template('result.html', name=name)
    return render_template('insecure.html')

File: templates/insecure.html

{% extends 'base.html' %}

{% block content %}
    <h1>Insecure Form</h1>
    <form method="post">
        <div class="form-group">
            <label>Name:</label>
            <input type="text" name="name" class="form-control">
        </div>
        <button type="submit" class="btn btn-primary">Submit</button>
    </form>
{% endblock %}

Issue: No CSRF token, vulnerable to CSRF attacks.

Correct: Use FlaskForm and form.hidden_tag() as shown in earlier examples.

Explanation:

  • Insecure: Missing CSRF protection allows attackers to submit malicious requests.
  • Correct: Flask-WTF’s CSRF protection prevents unauthorized submissions.

7.3 Practices to Avoid

  • Avoid Disabling CSRF: Only disable for non-sensitive forms with caution.
  • Avoid Weak SECRET_KEY: Use a cryptographically secure key.
  • Avoid Manual Tokens: Rely on form.hidden_tag() for CSRF tokens.

08. Conclusion

Flask-WTF’s CSRF protection is a critical feature for securing web forms in Flask applications, particularly for data-driven tasks involving sensitive user inputs. Key takeaways:

  • Use FlaskForm and form.hidden_tag() to enable CSRF protection.
  • Configure a strong SECRET_KEY and handle CSRF errors gracefully.
  • Secure AJAX and file upload forms with appropriate CSRF token handling.
  • Combine CSRF protection with input validation and sanitization for robust security.

By implementing Flask-WTF’s CSRF protection, you can safeguard your Flask applications against CSRF attacks, ensuring secure and reliable user interactions in dynamic, data-driven contexts!

Comments