Flask’s CSRF protection is one of those things you barely notice when it’s working — and absolutely cannot ignore when it’s not. You get a 400 Bad Request with no useful message, or a CSRFError: The CSRF token is missing buried in your logs, and suddenly your form is completely broken.

Quick Answer: Most Flask CSRF errors come down to four things: (1) forgetting form.hidden_tag() in your template, (2) AJAX requests not sending the token in headers, (3) tokens expiring after WTF_CSRF_TIME_LIMIT, or (4) tests running with CSRF enabled. Pick the scenario that matches yours below.

How to Diagnose the Root Cause

Before jumping to fixes, confirm exactly what error you’re seeing. Enable Flask’s debug logging or check your server logs:

import logging
logging.basicConfig(level=logging.DEBUG)

The three most common error messages are:

  • CSRFError: The CSRF token is missing. — token wasn’t sent at all
  • CSRFError: The CSRF token is invalid. — token sent but doesn’t match the session
  • CSRFError: The CSRF token has expired. — token was valid but too old

The message tells you which category you’re in. Now let’s walk through each cause.


Cause #1: Missing Token in Your HTML Form

This is by far the most common cause, especially when you’re new to Flask-WTF. If you’re rendering a form and your template doesn’t include the CSRF token field, every POST request gets rejected.

What it looks like:

CSRFError: The CSRF token is missing.

The fix is a one-liner. Flask-WTF’s FlaskForm automatically generates a hidden CSRF field, and form.hidden_tag() renders it. Always include it as the first line inside your <form> tag:

❌ Before (missing token):
<form method="POST" action="/submit">
  <input type="text" name="username">
  <button type="submit">Submit</button>
</form>
✅ After (token included):
<form method="POST" action="/submit">
  {{ form.hidden_tag() }}
  <input type="text" name="username">
  <button type="submit">Submit</button>
</form>

If you’re not using FlaskForm and are writing raw HTML forms, you can use the standalone csrf_token() function:

<form method="POST" action="/submit">
  <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
  <input type="text" name="username">
  <button type="submit">Submit</button>
</form>

One gotcha: if you copy-paste a form from a non-Flask project or a plain HTML template and forget to add this, the form will work fine with GET requests but fail on POST every single time. Double-check every form in your templates.


Cause #2: AJAX Requests Not Sending the Token

This trips up a lot of developers building single-page features inside Flask apps. Your regular HTML forms work fine, but your fetch() or XMLHttpRequest calls all return 400.

AJAX requests don’t automatically include the CSRF token — you have to add it manually to the request headers. Flask-WTF checks for the token in either the form body or the X-CSRFToken header.

Approach 1: Inject the token into the page and read it with JavaScript

In your base template, expose the token as a meta tag:

<head>
  <meta name="csrf-token" content="{{ csrf_token() }}">
</head>

Then in your JavaScript:

const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');

fetch('/api/submit', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRFToken': csrfToken
  },
  body: JSON.stringify({ username: 'alice' })
})
.then(response => response.json())
.then(data => console.log(data));

Approach 2: Set up a global Axios interceptor

If you’re using Axios, you can configure it once and never think about it again:

const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');

axios.defaults.headers.common['X-CSRFToken'] = csrfToken;

// All Axios requests now include the CSRF token automatically
axios.post('/api/submit', { username: 'alice' })
  .then(response => console.log(response.data));

Flask-side: make sure you’re reading the header

Flask-WTF’s CSRFProtect reads X-CSRFToken by default. If you’ve customized your CSRF configuration, check that WTF_CSRF_HEADERS includes 'X-CSRFToken':

app.config['WTF_CSRF_HEADERS'] = ['X-CSRFToken', 'X-CSRF-Token']

Cause #3: Token Expiry

Flask-WTF tokens expire. By default, WTF_CSRF_TIME_LIMIT is 3600 seconds (one hour). If a user loads a page and then comes back to submit the form after the token expires, they get a CSRF error.

CSRFError: The CSRF token has expired.

This is especially common on long forms, admin dashboards, or any page where users might leave a tab open overnight.

Option 1: Increase the time limit

# config.py
WTF_CSRF_TIME_LIMIT = 7200  # 2 hours, in seconds

Set this to None if you want tokens that never expire (only do this if you understand the security implications):

WTF_CSRF_TIME_LIMIT = None  # tokens never expire — use with caution

Option 2: Refresh the token with JavaScript

A more elegant approach is to silently refresh the CSRF token before submitting. Make a small endpoint that returns a fresh token:

from flask import jsonify
from flask_wtf.csrf import generate_csrf

@app.route('/csrf-token', methods=['GET'])
def get_csrf_token():
    return jsonify({'csrf_token': generate_csrf()})

Then in your JavaScript, refresh it before a long-running form submission:

async function refreshAndSubmit(formData) {
  const response = await fetch('/csrf-token');
  const { csrf_token } = await response.json();

  return fetch('/api/submit', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRFToken': csrf_token
    },
    body: JSON.stringify(formData)
  });
}

Option 3: Handle the error gracefully on the server side

Instead of showing users a raw 400 error, catch CSRFError and return something more helpful:

from flask_wtf.csrf import CSRFError

@app.errorhandler(CSRFError)
def handle_csrf_error(e):
    # Redirect back with a flash message, or return JSON for AJAX
    return jsonify({
        'error': 'Session expired. Please refresh the page and try again.',
        'code': 'CSRF_EXPIRED'
    }), 400

This is good UX and helps users understand what went wrong rather than staring at a blank 400 page.


Cause #4: CSRF Enabled During Tests

If your test suite suddenly starts failing with CSRF errors after you added Flask-WTF, the test client isn’t sending the token automatically. This causes all POST tests to fail.

The clean fix: disable CSRF in your test configuration.

# conftest.py
import pytest
from myapp import create_app

@pytest.fixture
def app():
    app = create_app({
        'TESTING': True,
        'WTF_CSRF_ENABLED': False,  # Disable CSRF for tests
        'SECRET_KEY': 'test-secret-key',
        'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:'
    })
    return app

@pytest.fixture
def client(app):
    return app.test_client()

Now your POST tests work without needing to extract and send CSRF tokens:

def test_submit_form(client):
    response = client.post('/submit', data={
        'username': 'alice',
        'email': 'alice@example.com'
    })
    assert response.status_code == 200

If you need to test CSRF behavior specifically (e.g., that your app properly rejects invalid tokens), you can enable CSRF just for those tests and manually send or omit the token:

def test_csrf_protection(client_with_csrf):
    # POST without token — should be rejected
    response = client_with_csrf.post('/submit', data={
        'username': 'alice'
    })
    assert response.status_code == 400

Cause #5: Blueprints Not Registered Correctly with CSRFProtect

When you use Flask Blueprints, you need to make sure CSRFProtect is initialized on the app object, not on individual blueprints. A common mistake is initializing CSRF protection before the app is fully configured.

❌ Wrong — partial initialization:
from flask_wtf.csrf import CSRFProtect
csrf = CSRFProtect()

# In blueprint:
csrf.init_app(blueprint)  # This doesn't work
✅ Correct — initialize on the app factory:
# extensions.py
from flask_wtf.csrf import CSRFProtect
csrf = CSRFProtect()

# app/__init__.py
from .extensions import csrf

def create_app(config=None):
    app = Flask(__name__)
    app.config.from_object(config or 'config.ProductionConfig')

    csrf.init_app(app)  # Always call init_app on the Flask app itself

    from .blueprints.auth import auth_bp
    app.register_blueprint(auth_bp)

    return app

If you have API endpoints that should be exempt from CSRF (common for REST APIs with token-based auth), use the @csrf.exempt decorator:

from myapp.extensions import csrf

@auth_bp.route('/api/login', methods=['POST'])
@csrf.exempt
def api_login():
    # JWT-authenticated endpoint, no CSRF needed
    data = request.get_json()
    # ... authentication logic
    return jsonify({'token': generate_token(data['user_id'])})

Still Not Working?

If you’ve checked all the above and still seeing CSRF errors, here are the less obvious culprits:

SECRET_KEY is not set or changes between requests. CSRF tokens are signed with your SECRET_KEY. If it’s None, changes on restart, or differs between workers in a multi-process deployment (Gunicorn with multiple workers), tokens will be invalid.

# Bad — key regenerates on every restart
app.config['SECRET_KEY'] = os.urandom(24)

# Good — stable key from environment
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev-only-fallback')

In production with multiple Gunicorn workers, set SECRET_KEY from an environment variable so all workers share the same key.

Reverse proxy stripping cookies. If you’re behind Nginx or a load balancer, confirm that session cookies are being forwarded correctly. CSRF tokens are tied to the session, so if the session cookie isn’t reaching Flask, the token comparison fails. Check your proxy headers configuration and make sure SESSION_COOKIE_SECURE, SESSION_COOKIE_SAMESITE, and SESSION_COOKIE_HTTPONLY are set appropriately for your deployment.

SameSite cookie policy conflicts. Modern browsers enforce SameSite cookie policies. If your Flask app is embedded in an iframe or making cross-origin requests, the session cookie might not be sent at all, which breaks CSRF token validation:

app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'  # or 'Strict' or 'None'
app.config['SESSION_COOKIE_SECURE'] = True  # Required when SameSite=None

This often surfaces in staging environments where the domain differs from production.

If you’re seeing a traceback in your logs and not sure how to read it, use Debugly’s trace formatter to quickly parse and analyze Python tracebacks — paste your error output and get a clean, readable breakdown.


Summary Checklist

Run through this list whenever you hit a CSRF error in Flask:

  • [ ] Every HTML form includes {{ form.hidden_tag() }} or <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
  • [ ] AJAX requests send X-CSRFToken in the request headers
  • [ ] WTF_CSRF_TIME_LIMIT is appropriate for your use case (increase for long-lived forms)
  • [ ] WTF_CSRF_ENABLED = False is set in test configuration
  • [ ] CSRFProtect is initialized via csrf.init_app(app) on the app object, not on blueprints
  • [ ] SECRET_KEY is stable and consistent across all workers/restarts
  • [ ] API endpoints that use token auth are decorated with @csrf.exempt
  • [ ] Session cookies are being forwarded correctly by any reverse proxy
  • [ ] SESSION_COOKIE_SAMESITE and SESSION_COOKIE_SECURE match your deployment environment

Most CSRF errors in Flask are configuration problems, not bugs in your application logic. Work through the checklist and you’ll find the issue quickly.

For related Flask debugging, check out our guide on Flask 400 Bad Request errors — CSRF failures show up as 400s, so that post has additional context on diagnosing the root cause. If you’re also using Flask-SQLAlchemy, our Flask SQLAlchemy session guide covers another common source of mysterious errors in Flask apps.