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.
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 allCSRFError: The CSRF token is invalid.— token sent but doesn’t match the sessionCSRFError: 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-CSRFTokenin the request headers - [ ]
WTF_CSRF_TIME_LIMITis appropriate for your use case (increase for long-lived forms) - [ ]
WTF_CSRF_ENABLED = Falseis set in test configuration - [ ]
CSRFProtectis initialized viacsrf.init_app(app)on the app object, not on blueprints - [ ]
SECRET_KEYis 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_SAMESITEandSESSION_COOKIE_SECUREmatch 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.