I am describing the condition under which exception “400 Bad Request: The CSRF session token is missing.” occurs in my application, and how I use the Flask application global exception handler to deal with this exception in a generic manner (or so I hope).

065-feature-image.png
Python: Flask “400 Bad Request: The CSRF session token is missing.”

To start off, I’d like to note that, based on what I’ve gathered during my research, “400 Bad Request: The CSRF session token is missing.” seems to happen under a variety of different conditions. In this post, I’m only describing a specific instance related to my application – it might not be relevant to similar problems you might have.

I’m using Flask-WTF, and I’ve CSRF Protection in place:

import flask
...
from flask_wtf.csrf import CSRFProtect
...

csrf = CSRFProtect()

...

def create_app(config=None):
    """Construct the core application."""

    app = flask.Flask(__name__, instance_relative_config=True)

    ...

    csrf.init_app(app)
	
	...

    return app

This exception occurs when I just stop at the login page, wait until the web session expires, and then log in.

My login form is pretty much a Flask-WTF recommended one, please note :

    <form method="post" action="" id="loginForm">
        
		
		...
    
        <h1 class="h3 mb-3 fw-normal">Please login</h1>

        <div class="form-floating">
            <input type="email" class="form-control" id="email" name="email" placeholder="name@example.com" data-parsley-trigger="change" required>
            <label for="email">Email address</label>
        </div>

		...

        <div class="form-floating">
            <input type="password" class="form-control" id="password" name="password" placeholder="Password" data-parsley-trigger="change" required>
            <label for="password">Password</label>
        </div>
		
		...
		
        <button class="w-100 btn btn-lg btn-primary" type="submit">Login</button>
    </form>

A log in when the web session is still valid sees the following form data submitted: csrf_token email and password, as per the screen capture below:

065-01-2.png

Flask-WTF’s def validate_csrf(data, secret_key=None, time_limit=None, token_key=None) in module csrf.py gets run before our code:

102
103
    if field_name not in session:
        raise ValidationError("The CSRF session token is missing.")

The value for field_name is 'csrf_token', and the session instance is:

<SqlAlchemySession {'_permanent': True, 'csrf_token': '616c3b20d4b1760fb04a6988befd05102b7e6422', '_fresh': False}>

And so the exception will not be raised, validate_csrf(...) exits successfully. Have we noticed that the value for the submitted csrf_token is different to the value shows in server code? There must be a conversion / translation takes place prior?

A log in when the web session has already been expired, still has csrf_token submitted. But the session instance shows:

<SqlAlchemySession {'_permanent': True}>

validate_csrf(...) will raise ValidationError("The CSRF session token is missing."). Since this exception is outside of the application code, it is handled by the application global exception handler def handle_error(e) method defined in the application module, app.py:

"""
App entry point.
"""

...
from bh_apistatus.result_status import make_500_status
...
from XXX import create_app
...
app = create_app()

@app.errorhandler(Exception)
def handle_error(e):
    """ 
    Global error handler for uncaught exceptions.
    Exceptions raised by @requires_access_role( ... ) will go to this handler.
    """
    return make_500_status(str(e)).as_dict()

make_500_status(...) is a method from the bh-apistatus library which I’ve written to manage data which would be returned as JSON. Browsers will then just display the exception as JSON – it makes absolutely no sense to the users. def handle_error(e) is to catch exceptions which the application code have not a chance to handle.

Now that I understand the condition under which “400 Bad Request: The CSRF session token is missing.” would occur, I’d like to redirect users to the login page again with a friendly message. Furthermore, I’d like to be able to configure how def handle_error(e) handles exceptions in a generic manner, I’d attempt to postulate that there are only a handful of exception which goes through this code path.

I came up with the following JSON configuration, which stores in a file named exception_config.json, under the application Instance Folders:

[
	{
		"typeName": "CSRFError",
		"code": 400,
		"name": "Bad Request",
		"description": "The CSRF session token is missing.",
		"action": {
			"name": "redirect",
			"data1": "auths.login",
			"data2": "Oops... Too much idle time has passed... Please try again."
		}
	}
]

We’ve seen that the exception being raised is ValidationError, but its type(e).__name__ is actually 'CSRFError', and it has three (3) attributes: code, name and description: when all four (4) pieces of information match, I’ll take the action, otherwise default to make_500_status(...).

Accordingly def handle_error(e) gets updated as follows:

@app.errorhandler(Exception)
def handle_error(e):
    """Global error handler for uncaught exceptions.

    Exceptions raised by @requires_access_role( ... ), \Lib\site-packages\flask_wtf\csrf.py's
      def validate_csrf(...) etc. will go to this handler.
    """
    import os
    import simplejson as json

    filename = os.path.join(app.instance_path, '', 'exception_config.json')
    with open(filename) as file:
        json_str = file.read()
    file.close()

    exception_config = json.loads(json_str)

    found = False
    action = None
    for itm in exception_config:
        if (itm['typeName'] != type(e).__name__): 
            continue
        
        if (itm['code'] == e.code and itm['name'] == e.name and 
            itm['description'] == e.description):
            action = itm['action']
            found = True
            break

    if found:
        if (action['name'] == 'redirect'): 
            flash(action['data2'], 'danger')
            return redirect(url_for(action['data1']))

    return make_500_status(str(e)).as_dict()

It should be self-explanatory. I don’t yet know what other exceptions might come through this code path, I’ll leave it at this implementation for the time being. I’m sure this would not be its final shape.

With this update in place, the response is much friendlier:

065-02.png

I hope this post is useful. Thank you for reading and stay safe as always.

✿✿✿

Feature image sources: