From 7bda7442c9182dff158241b1883db04e32823730 Mon Sep 17 00:00:00 2001 From: Phineas Jensen Date: Tue, 25 Aug 2026 10:20:49 -0600 Subject: [PATCH 1/7] Add config option for Signer digest methods --- src/flask_wtf/csrf.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/flask_wtf/csrf.py b/src/flask_wtf/csrf.py index c3f1a4b0..5f5530dc 100644 --- a/src/flask_wtf/csrf.py +++ b/src/flask_wtf/csrf.py @@ -21,6 +21,8 @@ __all__ = ("generate_csrf", "validate_csrf", "csrf_meta_tag", "CSRFProtect") logger = logging.getLogger(__name__) +DEFAULT_CSRF_SIGNER_DIGEST_METHOD = hashlib.sha1 + def generate_csrf(secret_key=None, token_key=None): """Generate a CSRF token. The token is cached for a request, so multiple @@ -49,7 +51,15 @@ def generate_csrf(secret_key=None, token_key=None): ) if field_name not in g: - s = URLSafeTimedSerializer(secret_key, salt="wtf-csrf-token") + s = URLSafeTimedSerializer( + secret_key, + salt="wtf-csrf-token", + signer_kwargs={ + "digest_method": current_app.config.get( + "WTF_CSRF_SIGNER_DIGEST_METHOD", DEFAULT_CSRF_SIGNER_DIGEST_METHOD + ) + }, + ) if field_name not in session: session[field_name] = hashlib.sha1(os.urandom(64)).hexdigest() @@ -104,7 +114,15 @@ def validate_csrf(data, secret_key=None, time_limit=None, token_key=None): if field_name not in session: raise ValidationError("The CSRF session token is missing.") - s = URLSafeTimedSerializer(secret_key, salt="wtf-csrf-token") + s = URLSafeTimedSerializer( + secret_key, + salt="wtf-csrf-token", + signer_kwargs={ + "digest_method": current_app.config.get( + "WTF_CSRF_SIGNER_DIGEST_METHOD", DEFAULT_CSRF_SIGNER_DIGEST_METHOD + ) + }, + ) try: token = s.loads(data, max_age=time_limit) @@ -221,6 +239,9 @@ def init_app(self, app): app.config.setdefault("WTF_CSRF_META_NAME", "csrf-token") app.config.setdefault("WTF_CSRF_TIME_LIMIT", 3600) app.config.setdefault("WTF_CSRF_SSL_STRICT", True) + app.config.setdefault( + "WTF_CSRF_SIGNER_DIGEST_METHOD", DEFAULT_CSRF_SIGNER_DIGEST_METHOD + ) app.jinja_env.globals["csrf_token"] = generate_csrf app.jinja_env.globals["csrf_meta_tag"] = csrf_meta_tag From 1ac260ed8a1d81c2ed5122cf2418f4d72c4a02bd Mon Sep 17 00:00:00 2001 From: Phineas Jensen Date: Tue, 25 Aug 2026 12:12:49 -0600 Subject: [PATCH 2/7] Add docs for CSRF signing digest method --- docs/config.rst | 69 ++++++++++++++++++++++++++----------------------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/docs/config.rst b/docs/config.rst index 1ecd20fc..9c928db9 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -1,40 +1,45 @@ Configuration ============= -========================== ===================================================== -``WTF_CSRF_ENABLED`` Set to ``False`` to disable all CSRF protection. - Default is ``True``. -``WTF_CSRF_CHECK_DEFAULT`` When using the CSRF protection extension, this - controls whether every view is protected by default. - Default is ``True``. -``WTF_CSRF_SECRET_KEY`` Random data for generating secure tokens. If this is - not set then ``SECRET_KEY`` is used. -``WTF_CSRF_METHODS`` HTTP methods to protect from CSRF. Default is - ``{'POST', 'PUT', 'PATCH', 'DELETE'}``. -``WTF_CSRF_FIELD_NAME`` Name of the form field and session key that holds the - CSRF token. Default is ``csrf_token``. -``WTF_CSRF_HEADERS`` HTTP headers to search for CSRF token when it is not - provided in the form. Default is - ``['X-CSRFToken', 'X-CSRF-Token']``. -``WTF_CSRF_META_NAME`` Value of the ``name`` attribute rendered by - :func:`~flask_wtf.csrf.csrf_meta_tag`. Default is - ``csrf-token``. -``WTF_CSRF_TIME_LIMIT`` Max age in seconds for CSRF tokens. Default is - ``3600``. If set to ``None``, the CSRF token is valid - for the life of the session. - If your webserver has a cache policy, make sure it is - configured with at maximum this value, so user browsers - won't display pages with expired CSRF tokens. -``WTF_CSRF_SSL_STRICT`` Whether to enforce the same origin policy by checking - that the referrer matches the host. Only applies to - HTTPS requests. Default is ``True``. -``WTF_I18N_ENABLED`` Set to ``False`` to disable Flask-Babel I18N support. - Also set to ``False`` if you want to use WTForms's - built-in messages directly, see more info `here`_. - Default is ``True``. -========================== ===================================================== +================================= ===================================================== +``WTF_CSRF_ENABLED`` Set to ``False`` to disable all CSRF protection. + Default is ``True``. +``WTF_CSRF_CHECK_DEFAULT`` When using the CSRF protection extension, this + controls whether every view is protected by default. + Default is ``True``. +``WTF_CSRF_SECRET_KEY`` Random data for generating secure tokens. If this is + not set then ``SECRET_KEY`` is used. +``WTF_CSRF_METHODS`` HTTP methods to protect from CSRF. Default is + ``{'POST', 'PUT', 'PATCH', 'DELETE'}``. +``WTF_CSRF_FIELD_NAME`` Name of the form field and session key that holds the + CSRF token. Default is ``csrf_token``. +``WTF_CSRF_HEADERS`` HTTP headers to search for CSRF token when it is not + provided in the form. Default is + ``['X-CSRFToken', 'X-CSRF-Token']``. +``WTF_CSRF_META_NAME`` Value of the ``name`` attribute rendered by + :func:`~flask_wtf.csrf.csrf_meta_tag`. Default is + ``csrf-token``. +``WTF_CSRF_TIME_LIMIT`` Max age in seconds for CSRF tokens. Default is + ``3600``. If set to ``None``, the CSRF token is valid + for the life of the session. + If your webserver has a cache policy, make sure it is + configured with at maximum this value, so user browsers + won't display pages with expired CSRF tokens. +``WTF_CSRF_SSL_STRICT`` Whether to enforce the same origin policy by checking + that the referrer matches the host. Only applies to + HTTPS requests. Default is ``True``. +``WTF_I18N_ENABLED`` Set to ``False`` to disable Flask-Babel I18N support. + Also set to ``False`` if you want to use WTForms's + built-in messages directly, see more info `here`_. + Default is ``True``. +``WTF_CSRF_SIGNER_DIGEST_METHOD`` Set to an algorithm from the `hashlib`_ library to + configure which hash algorithm is used to sign CSRF + tokens. + Default is ``hashlib.sha1``. +================================= ===================================================== .. _here: https://wtforms.readthedocs.io/en/stable/i18n.html#using-the-built-in-translations-provider +.. _hashlib: https://docs.python.org/3/library/hashlib.html Recaptcha --------- From c95257c5a10e20abf8760d484b4f89e0e307e37c Mon Sep 17 00:00:00 2001 From: Phineas Jensen Date: Tue, 25 Aug 2026 13:16:56 -0600 Subject: [PATCH 3/7] Add test and changelog entry for CSRF signing digest config --- docs/changes.rst | 3 +++ tests/test_csrf_extension.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index d76b3a61..7053057b 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -1,6 +1,9 @@ Changes ======= +- Allow configuring the hash algorithm used by the CSRF signer using + ``WTF_CSRF_SIGNER_DIGEST_METHOD`` + Version 1.3.0 ------------- diff --git a/tests/test_csrf_extension.py b/tests/test_csrf_extension.py index 2248278d..31d09a4f 100644 --- a/tests/test_csrf_extension.py +++ b/tests/test_csrf_extension.py @@ -1,3 +1,5 @@ +import hashlib + import pytest from flask import Blueprint from flask import g @@ -269,3 +271,15 @@ def assert_info(message): client.post("/") assert len(messages) == 1 assert messages[0] == "The CSRF token is missing." + + +def test_csrf_config(app, req_ctx): + app.config["WTF_CSRF_SIGNER_DIGEST_METHOD"] = hashlib.sha1 + sha1_token = generate_csrf() + assert len(sha1_token) == 91 + + # Reset global variable so the token is regenerated + g.pop(app.config["WTF_CSRF_FIELD_NAME"]) + app.config["WTF_CSRF_SIGNER_DIGEST_METHOD"] = hashlib.sha256 + sha256_token = generate_csrf() + assert len(sha256_token) == 107 From 6d72f017679bf610b8e562ac3579b2a4b8a47884 Mon Sep 17 00:00:00 2001 From: Phineas Jensen Date: Tue, 25 Aug 2026 14:27:10 -0600 Subject: [PATCH 4/7] serializer: use default hash algorithm unless set by user --- src/flask_wtf/csrf.py | 43 ++++++++++++++++++++----------------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/src/flask_wtf/csrf.py b/src/flask_wtf/csrf.py index 5f5530dc..892c27c2 100644 --- a/src/flask_wtf/csrf.py +++ b/src/flask_wtf/csrf.py @@ -21,8 +21,6 @@ __all__ = ("generate_csrf", "validate_csrf", "csrf_meta_tag", "CSRFProtect") logger = logging.getLogger(__name__) -DEFAULT_CSRF_SIGNER_DIGEST_METHOD = hashlib.sha1 - def generate_csrf(secret_key=None, token_key=None): """Generate a CSRF token. The token is cached for a request, so multiple @@ -51,15 +49,7 @@ def generate_csrf(secret_key=None, token_key=None): ) if field_name not in g: - s = URLSafeTimedSerializer( - secret_key, - salt="wtf-csrf-token", - signer_kwargs={ - "digest_method": current_app.config.get( - "WTF_CSRF_SIGNER_DIGEST_METHOD", DEFAULT_CSRF_SIGNER_DIGEST_METHOD - ) - }, - ) + s = _get_serializer(secret_key) if field_name not in session: session[field_name] = hashlib.sha1(os.urandom(64)).hexdigest() @@ -114,15 +104,7 @@ def validate_csrf(data, secret_key=None, time_limit=None, token_key=None): if field_name not in session: raise ValidationError("The CSRF session token is missing.") - s = URLSafeTimedSerializer( - secret_key, - salt="wtf-csrf-token", - signer_kwargs={ - "digest_method": current_app.config.get( - "WTF_CSRF_SIGNER_DIGEST_METHOD", DEFAULT_CSRF_SIGNER_DIGEST_METHOD - ) - }, - ) + s = _get_serializer(secret_key) try: token = s.loads(data, max_age=time_limit) @@ -177,6 +159,23 @@ def _get_config( return value +def _get_serializer(secret_key): + """Create and return a Serializer to be used for CSRF tokens. + + :param secret_key: secret key used to sign the token + """ + serializer_kwargs = { + "salt": "wtf-csrf-token", + } + digest_method = current_app.config.get("WTF_CSRF_SIGNER_DIGEST_METHOD") + if digest_method is not None: + serializer_kwargs["signer_kwargs"] = {"digest_method": digest_method} + return URLSafeTimedSerializer( + secret_key, + **serializer_kwargs, + ) + + class _FlaskFormCSRF(CSRF): def setup_form(self, form): self.meta = form.meta @@ -239,9 +238,7 @@ def init_app(self, app): app.config.setdefault("WTF_CSRF_META_NAME", "csrf-token") app.config.setdefault("WTF_CSRF_TIME_LIMIT", 3600) app.config.setdefault("WTF_CSRF_SSL_STRICT", True) - app.config.setdefault( - "WTF_CSRF_SIGNER_DIGEST_METHOD", DEFAULT_CSRF_SIGNER_DIGEST_METHOD - ) + app.config.setdefault("WTF_CSRF_SIGNER_DIGEST_METHOD", None) app.jinja_env.globals["csrf_token"] = generate_csrf app.jinja_env.globals["csrf_meta_tag"] = csrf_meta_tag From 72c7d02e9bb796c9c205838d518240404f0fb504 Mon Sep 17 00:00:00 2001 From: Phineas Jensen Date: Tue, 25 Aug 2026 14:28:14 -0600 Subject: [PATCH 5/7] docs: Use :mod: link to hashlib --- docs/config.rst | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/config.rst b/docs/config.rst index 9c928db9..9bf0eeac 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -32,14 +32,12 @@ Configuration Also set to ``False`` if you want to use WTForms's built-in messages directly, see more info `here`_. Default is ``True``. -``WTF_CSRF_SIGNER_DIGEST_METHOD`` Set to an algorithm from the `hashlib`_ library to +``WTF_CSRF_SIGNER_DIGEST_METHOD`` Set to an algorithm from the :mod:`hashlib` library to configure which hash algorithm is used to sign CSRF - tokens. - Default is ``hashlib.sha1``. + tokens. Default is ``hashlib.sha1``. ================================= ===================================================== .. _here: https://wtforms.readthedocs.io/en/stable/i18n.html#using-the-built-in-translations-provider -.. _hashlib: https://docs.python.org/3/library/hashlib.html Recaptcha --------- From fb0f8cdd82d124aa489a71685faea50c07f79c92 Mon Sep 17 00:00:00 2001 From: Phineas Jensen Date: Tue, 25 Aug 2026 16:10:50 -0600 Subject: [PATCH 6/7] Make Signer config more general --- src/flask_wtf/csrf.py | 16 ++++++++-------- tests/test_csrf_extension.py | 24 ++++++++++++++---------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/flask_wtf/csrf.py b/src/flask_wtf/csrf.py index 892c27c2..c662190a 100644 --- a/src/flask_wtf/csrf.py +++ b/src/flask_wtf/csrf.py @@ -164,16 +164,16 @@ def _get_serializer(secret_key): :param secret_key: secret key used to sign the token """ - serializer_kwargs = { + kwargs = { "salt": "wtf-csrf-token", } - digest_method = current_app.config.get("WTF_CSRF_SIGNER_DIGEST_METHOD") - if digest_method is not None: - serializer_kwargs["signer_kwargs"] = {"digest_method": digest_method} - return URLSafeTimedSerializer( - secret_key, - **serializer_kwargs, - ) + if "WTF_CSRF_SIGNER" in current_app.config: + kwargs["signer"] = current_app.config["WTF_CSRF_SIGNER"] + + if "WTF_CSRF_SIGNER_KWARGS" in current_app.config: + kwargs["signer_kwargs"] = current_app.config["WTF_CSRF_SIGNER_KWARGS"] + + return URLSafeTimedSerializer(secret_key, **kwargs) class _FlaskFormCSRF(CSRF): diff --git a/tests/test_csrf_extension.py b/tests/test_csrf_extension.py index 31d09a4f..6d624f3d 100644 --- a/tests/test_csrf_extension.py +++ b/tests/test_csrf_extension.py @@ -273,13 +273,17 @@ def assert_info(message): assert messages[0] == "The CSRF token is missing." -def test_csrf_config(app, req_ctx): - app.config["WTF_CSRF_SIGNER_DIGEST_METHOD"] = hashlib.sha1 - sha1_token = generate_csrf() - assert len(sha1_token) == 91 - - # Reset global variable so the token is regenerated - g.pop(app.config["WTF_CSRF_FIELD_NAME"]) - app.config["WTF_CSRF_SIGNER_DIGEST_METHOD"] = hashlib.sha256 - sha256_token = generate_csrf() - assert len(sha256_token) == 107 +def test_csrf_signer_config(app, req_ctx): + from itsdangerous.signer import Signer + + class TestSigner(Signer): + def sign(self, value): + return super().sign(value) + b"-test" + + app.config["WTF_CSRF_SIGNER"] = TestSigner + assert generate_csrf().endswith("-test") + + +def test_csrf_signer_kwargs_config(app, req_ctx): + app.config["WTF_CSRF_SIGNER_KWARGS"] = {"digest_method": hashlib.sha256} + assert len(generate_csrf()) == 107 From 39e3b6e569e66fa3721b86d0099c0f4a16be4ab3 Mon Sep 17 00:00:00 2001 From: Phineas Jensen Date: Tue, 25 Aug 2026 16:18:22 -0600 Subject: [PATCH 7/7] Fix signer config and update docs --- docs/changes.rst | 4 +-- docs/config.rst | 73 ++++++++++++++++++++++--------------------- src/flask_wtf/csrf.py | 3 +- 3 files changed, 42 insertions(+), 38 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 7053057b..3748920b 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -1,8 +1,8 @@ Changes ======= -- Allow configuring the hash algorithm used by the CSRF signer using - ``WTF_CSRF_SIGNER_DIGEST_METHOD`` +- Allow configuration of the CSRF token signer using ``WTF_CSRF_SIGNER`` and + ``WTF_CSRF_SIGNER_KWARGS`` Version 1.3.0 ------------- diff --git a/docs/config.rst b/docs/config.rst index 9bf0eeac..c5c62038 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -1,43 +1,46 @@ Configuration ============= -================================= ===================================================== -``WTF_CSRF_ENABLED`` Set to ``False`` to disable all CSRF protection. - Default is ``True``. -``WTF_CSRF_CHECK_DEFAULT`` When using the CSRF protection extension, this - controls whether every view is protected by default. - Default is ``True``. -``WTF_CSRF_SECRET_KEY`` Random data for generating secure tokens. If this is - not set then ``SECRET_KEY`` is used. -``WTF_CSRF_METHODS`` HTTP methods to protect from CSRF. Default is - ``{'POST', 'PUT', 'PATCH', 'DELETE'}``. -``WTF_CSRF_FIELD_NAME`` Name of the form field and session key that holds the - CSRF token. Default is ``csrf_token``. -``WTF_CSRF_HEADERS`` HTTP headers to search for CSRF token when it is not - provided in the form. Default is - ``['X-CSRFToken', 'X-CSRF-Token']``. -``WTF_CSRF_META_NAME`` Value of the ``name`` attribute rendered by - :func:`~flask_wtf.csrf.csrf_meta_tag`. Default is - ``csrf-token``. -``WTF_CSRF_TIME_LIMIT`` Max age in seconds for CSRF tokens. Default is - ``3600``. If set to ``None``, the CSRF token is valid - for the life of the session. - If your webserver has a cache policy, make sure it is - configured with at maximum this value, so user browsers - won't display pages with expired CSRF tokens. -``WTF_CSRF_SSL_STRICT`` Whether to enforce the same origin policy by checking - that the referrer matches the host. Only applies to - HTTPS requests. Default is ``True``. -``WTF_I18N_ENABLED`` Set to ``False`` to disable Flask-Babel I18N support. - Also set to ``False`` if you want to use WTForms's - built-in messages directly, see more info `here`_. - Default is ``True``. -``WTF_CSRF_SIGNER_DIGEST_METHOD`` Set to an algorithm from the :mod:`hashlib` library to - configure which hash algorithm is used to sign CSRF - tokens. Default is ``hashlib.sha1``. -================================= ===================================================== +========================== ===================================================== +``WTF_CSRF_ENABLED`` Set to ``False`` to disable all CSRF protection. + Default is ``True``. +``WTF_CSRF_CHECK_DEFAULT`` When using the CSRF protection extension, this + controls whether every view is protected by default. + Default is ``True``. +``WTF_CSRF_SECRET_KEY`` Random data for generating secure tokens. If this is + not set then ``SECRET_KEY`` is used. +``WTF_CSRF_METHODS`` HTTP methods to protect from CSRF. Default is + ``{'POST', 'PUT', 'PATCH', 'DELETE'}``. +``WTF_CSRF_FIELD_NAME`` Name of the form field and session key that holds the + CSRF token. Default is ``csrf_token``. +``WTF_CSRF_HEADERS`` HTTP headers to search for CSRF token when it is not + provided in the form. Default is + ``['X-CSRFToken', 'X-CSRF-Token']``. +``WTF_CSRF_META_NAME`` Value of the ``name`` attribute rendered by + :func:`~flask_wtf.csrf.csrf_meta_tag`. Default is + ``csrf-token``. +``WTF_CSRF_TIME_LIMIT`` Max age in seconds for CSRF tokens. Default is + ``3600``. If set to ``None``, the CSRF token is valid + for the life of the session. + If your webserver has a cache policy, make sure it is + configured with at maximum this value, so user browsers + won't display pages with expired CSRF tokens. +``WTF_CSRF_SSL_STRICT`` Whether to enforce the same origin policy by checking + that the referrer matches the host. Only applies to + HTTPS requests. Default is ``True``. +``WTF_I18N_ENABLED`` Set to ``False`` to disable Flask-Babel I18N support. + Also set to ``False`` if you want to use WTForms's + built-in messages directly, see more info `here`_. + Default is ``True``. +``WTF_CSRF_SIGNER`` Set to a subclass of the `Signer`_ class to use + custom token-signing behavior. Default is the default + for the ``itsdangerous`` library. +``WTF_CSRF_SIGNER_KWARGS`` Controls the kwargs passed to the Signer class. + Default is ``None``. +========================== ===================================================== .. _here: https://wtforms.readthedocs.io/en/stable/i18n.html#using-the-built-in-translations-provider +.. _Signer: https://itsdangerous.palletsprojects.com/en/stable/signer/ Recaptcha --------- diff --git a/src/flask_wtf/csrf.py b/src/flask_wtf/csrf.py index c662190a..f4c7737e 100644 --- a/src/flask_wtf/csrf.py +++ b/src/flask_wtf/csrf.py @@ -238,7 +238,8 @@ def init_app(self, app): app.config.setdefault("WTF_CSRF_META_NAME", "csrf-token") app.config.setdefault("WTF_CSRF_TIME_LIMIT", 3600) app.config.setdefault("WTF_CSRF_SSL_STRICT", True) - app.config.setdefault("WTF_CSRF_SIGNER_DIGEST_METHOD", None) + app.config.setdefault("WTF_CSRF_SIGNER", None) + app.config.setdefault("WTF_CSRF_SIGNER_KWARGS", None) app.jinja_env.globals["csrf_token"] = generate_csrf app.jinja_env.globals["csrf_meta_tag"] = csrf_meta_tag