CSRF Protection

Difficulty: Intermediate

Cross-Site Request Forgery (CSRF) is an attack where a malicious website tricks a user's browser into making unwanted requests to your site. Because the browser automatically sends cookies (including session cookies), the request appears legitimate. CSRF protection ensures that form submissions originate from your own site.

Flask-WTF provides CSRF protection by default for all forms. When you render a form with {{ form.hidden_tag() }}, Flask-WTF embeds a unique CSRF token as a hidden input field. When the form is submitted, Flask-WTF validates this token. If the token is missing, expired, or invalid, the submission is rejected with a 400 error.

The CSRF token is generated using the app's SECRET_KEY and is unique to each session. It is included in the form as a hidden field named 'csrf_token'. The token is also set in a cookie for AJAX requests to read. This double-submit pattern (token in form and cookie) provides strong CSRF protection.

For applications that also serve APIs, you may need to exempt certain routes from CSRF protection. JSON API endpoints that use authentication tokens (like JWT) do not need CSRF protection because they do not rely on cookies. Use @csrf.exempt on specific views or the CSRFProtect.exempt() method on blueprints.

For AJAX requests, you cannot include the CSRF token in a form field. Instead, read the token from a meta tag or cookie and include it in the X-CSRFToken request header. Flask-WTF checks this header as an alternative to the form field. Set the meta tag with {{ csrf_token() }} and configure your AJAX library to include it.

CSRFProtect can be initialized as a standalone extension (without FlaskForm) to protect all POST/PUT/DELETE requests globally. This is useful when you have views that process form data without using WTForms.

The CSRF token has a configurable expiration time (WTF_CSRF_TIME_LIMIT, default 3600 seconds). Long forms or slow users may encounter expired tokens. Consider increasing the timeout or showing a user-friendly message when the token expires.

Code examples

Basic CSRF Protection

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

app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key'
app.config['WTF_CSRF_TIME_LIMIT'] = 3600  # Token expiry in seconds

# Global CSRF protection (even for non-WTForms views)
csrf = CSRFProtect(app)

class PostForm(FlaskForm):
    title = StringField('Title', validators=[DataRequired()])
    submit = SubmitField('Post')

@app.route('/post', methods=['GET', 'POST'])
def create_post():
    form = PostForm()
    if form.validate_on_submit():  # Includes CSRF check
        return f'Created: {form.title.data}'
    return render_template('post.html', form=form)

# Template must include:
# <form method="POST">
#     {{ form.hidden_tag() }}  <!-- Includes CSRF token -->
#     ...
# </form>

CSRF protection is automatic with FlaskForm. The hidden_tag() renders the CSRF token. CSRFProtect adds global protection for non-WTForms views. The token is validated on every POST submission.

CSRF with AJAX Requests

<!-- In your base template -->
<meta name="csrf-token" content="{{ csrf_token() }}">

<script>
// Read the CSRF token from the meta tag
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;

// Include in fetch requests
fetch('/api/data', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-CSRFToken': csrfToken
    },
    body: JSON.stringify({ key: 'value' })
});

// Or configure globally with Axios
// axios.defaults.headers.common['X-CSRFToken'] = csrfToken;
</script>

For AJAX, embed the CSRF token in a meta tag using {{ csrf_token() }}. JavaScript reads it and includes it in the X-CSRFToken header. Flask-WTF checks this header for non-form requests.

Exempting Routes from CSRF

from flask import Flask, Blueprint
from flask_wtf import CSRFProtect

app = Flask(__name__)
csrf = CSRFProtect(app)

# Exempt a specific view
@app.route('/webhook', methods=['POST'])
@csrf.exempt
def webhook():
    """External services cannot provide CSRF tokens."""
    return 'OK'

# Exempt an entire blueprint
api_bp = Blueprint('api', __name__)
csrf.exempt(api_bp)

@api_bp.route('/data', methods=['POST'])
def api_data():
    """API uses JWT auth, not cookies, so CSRF is unnecessary."""
    return {'data': 'value'}

Use @csrf.exempt for routes that cannot provide CSRF tokens (webhooks, external callbacks). Exempt entire blueprints for API routes that use token-based authentication instead of cookies.

Custom CSRF Error Handler

from flask import Flask, jsonify
from flask_wtf import CSRFProtect
from flask_wtf.csrf import CSRFError

app = Flask(__name__)
app.config['SECRET_KEY'] = 'secret'
csrf = CSRFProtect(app)

@app.errorhandler(CSRFError)
def handle_csrf_error(e):
    """Return JSON error instead of default 400 HTML page."""
    return jsonify({
        'error': 'CSRF validation failed',
        'message': e.description
    }), 400

Custom error handlers for CSRFError provide user-friendly messages. The default is a plain 400 error page. For APIs, return JSON. For web apps, show a flash message and re-render the form.

Key points

Concepts covered

CSRF, CSRF Token, hidden_tag, CSRFProtect, Exempt Routes, AJAX CSRF