security beginner

CSRF Protection in Flask Forms

min read Frederick Tubiermont

CSRF Protection in Flask Forms

CSRF (Cross-Site Request Forgery) tricks a user's browser into submitting a form to your app from a different website. The fix is simple: include a secret token in every form that only your server knows.

What a CSRF Attack Looks Like

  1. User logs into your bank at bank.com
  2. User visits malicious evil.com
  3. evil.com has a hidden form that POSTs to bank.com/transfer
  4. The user's browser sends their bank.com session cookie automatically
  5. The bank processes the transfer — the user never clicked anything

The defense: require a token in the form that evil.com cannot know.

The Simple Approach: Generate a Token Per Session

# utils/csrf.py
import secrets
from flask import session

def generate_csrf_token():
    """Generate a CSRF token and store it in the session."""
    if "csrf_token" not in session:
        session["csrf_token"] = secrets.token_hex(32)
    return session["csrf_token"]

def validate_csrf_token(token):
    """Check submitted token against the session token."""
    session_token = session.get("csrf_token")
    if not session_token or not token:
        return False
    # Use secrets.compare_digest to prevent timing attacks
    return secrets.compare_digest(session_token, token)

Add the Token to Every Form

Make generate_csrf_token available in all templates:

# app.py
from utils.csrf import generate_csrf_token

app.jinja_env.globals["csrf_token"] = generate_csrf_token

Now in every form:

<!-- templates/any-form.html -->
<form method="POST" action="/submit">
    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
    <input type="text" name="name" placeholder="Your name">
    <button type="submit">Submit</button>
</form>

Validate on Every POST Route

# utils/csrf.py
from functools import wraps
from flask import request, abort
from utils.csrf import validate_csrf_token

def csrf_protect(f):
    """Decorator to validate CSRF token on POST requests."""
    @wraps(f)
    def decorated(*args, **kwargs):
        if request.method == "POST":
            token = request.form.get("csrf_token", "")
            if not validate_csrf_token(token):
                abort(403)  # Forbidden
        return f(*args, **kwargs)
    return decorated
# In your routes
from utils.csrf import csrf_protect

@app.route("/profile/update", methods=["GET", "POST"])
@login_required
@csrf_protect
def update_profile():
    if request.method == "POST":
        name = request.form.get("name")
        # Safe — CSRF token already validated
        with get_db() as conn:
            with conn.cursor() as cur:
                cur.execute(
                    "UPDATE users SET name = %s WHERE id = %s",
                    (name, session["user_id"])
                )
                conn.commit()
        return redirect("/profile")
    return render_template("profile.html")

CSRF for AJAX Requests

For fetch() calls from JavaScript, include the token in a header:

<!-- Make token available to JS -->
<meta name="csrf-token" content="{{ csrf_token() }}">
// Read token from meta tag
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;

// Include in every POST fetch
async function postData(url, data) {
    const response = await fetch(url, {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
            "X-CSRF-Token": csrfToken
        },
        body: JSON.stringify(data)
    });
    return response.json();
}
# Validate header in API routes
def csrf_protect_ajax(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        if request.method == "POST":
            token = request.headers.get("X-CSRF-Token", "")
            if not validate_csrf_token(token):
                return {"error": "Invalid CSRF token"}, 403
        return f(*args, **kwargs)
    return decorated

When You Don't Need CSRF Protection

  • GET requests: Never use GET for actions that change data
  • API endpoints with Authorization headers: These can't be forged by cross-site forms
  • JSON-only APIs: If you check Content-Type: application/json, browsers can't forge this from forms

Flask-WTF: The Automatic Alternative

If you use Flask-WTF for forms, CSRF protection is built in:

from flask_wtf import FlaskForm
from wtforms import StringField
from flask_wtf.csrf import CSRFProtect

csrf = CSRFProtect(app)  # Protects ALL forms automatically

The manual approach above is better for understanding what's happening. Flask-WTF is better when you have many forms and want it handled automatically.

Was this helpful?

Get More Flask Vibe Tutorials

Join 1,000+ developers getting weekly Flask tips and AI-friendly code patterns.

No spam. Unsubscribe anytime.