Signing the CSRF cookie

Django should sign it’s CSRF cookie and check for the signature as part of CSRF validation. This would have broad security benefits; in particular would add CSRF cookie tampering protection for websites hosted on subdomains of a shared domain name (ex. [SUBDOMAIN].herokuapp.com). This would also reduce the caveat currently under ​https://docs.djangoproject.com/en/5.1/ref/csrf/#csrf-limitations). This is an important feature for Django being used for government websites, were sites are often hosted on a subdomain of a domain shared with many other websites.

The only downside I can see is backwards incompatibility, in that every user would have an invalid CSRF token upon rolling out this update. The performance impact of signing the CSRF token is negligible enough that session cookies are regularly signed by Django without issue. I’ve also run a modified Django CSRF middleware that signs CSRF cookies on production sites without performance impact for years.

I see three pathways to achieve this:

  1. The most straightforward is to add a setting CSRF_COOKIE_SIGNED that initially defaults to False. People could then switch to their sites, and this setting could get changed to default to True or be deprecated in a future release. Sample implementation: https://github.com/django/django/compare/main…zags:django:ticket_35796. This has the downside of introducing a new setting (at least temporarily).

  2. Change the CSRF middleware to exclusively set signed cookies, but still accept unsigned cookies as part of checking validity for one release and then deprecate this in the next release. This has the downside of taking an extra major release to increase Django’s security posture.

  3. Change the CSRF middleware to exclusively set signed cookies, and if it encounters an unsigned cookie, attempt a graceful rejection that sets a new cookie and reloads the form. This has the downside of high implementation and testing complexity.

I believe option #1 is the best (and already have code for it), but are there considerations or other approaches I’ve missed?

in particular would add CSRF cookie tampering protection for websites hosted on subdomains of a shared domain name

While tampering is probably possible the fallout should be minimal in combination with the origin checking I think.

The only downside I can see is backwards incompatibility

This one is large though. I know some companies who had really big issues with the previous CSRF changes. So whatever we do we have to ensure that we have an upgrade path where an operator can upgrade and the enduser doesn’t have to clear browser cookies to get the site back working.

As for your options, I think a combination of all is needed. Essentially the setting doesn’t have to be a boolean but could be tri-state (disabled, lenient – ie allow legacy as well, strict – ie only signed cookies). But even in strict mode we want to be able to detect that a legacy cookie was sent and generate a new one so the upgrade is transparent in most cases.

The combination is an interesting idea. I don’t think the setting would need three states though. What about the following?

The initial release of this change adds a setting CSRF_COOKIE_SIGNATURE_REQUIRED that defaults to False. All new CSRF cookies are created signed. If this setting is False, Django accepts both signed and unsigned CSRF cookies. If this setting is True, Django only accepts signed cookies.

In a subsequent major release, the CSRF_COOKIE_SIGNATURE_REQUIRED flag is changed to default to True and is deprecated. It can eventually be removed, with the True behavior of this flag becoming the only code pathway.

Signing all cookies by default is not an option because older Django versions will reject it. This situation will happen when you deploy your app.over multiple servers and upgrade one by one. You should always consider the fact that an old version not knowing anything about this is still running.

Good point; I was over-assuming zero-downtime deployment strategies. Revised plan follows.

Setting CSRF_COOKIE_SIGNING takes three values:

  1. "disabled" (default): CSRF token is not signed, but CSRF middleware accepts both signed and unsigned cookies.
  2. "lenient": CSRF token is signed, and CSRF middleware accepts both signed and unsigned cookies.
  3. "strict": CSRF token is signed, and CSRF middleware only accepts signed cookies.

This would enable people to upgrade their systems over two releases with no disruption to users, stepping from "disabled" to "lenient" in one and "lenient" to "strict" in the second.

In a future Django release, this setting could get changed to a default of "lenient" and deprecated.

For another data point, I’m asking similar questions regarding handling deprecation for password reset URLs with tokens formatted with what it will eventually be the “old way”. See Refs #35730: Encrypted 'uid’ parameter instead of base64-encoding to prevent possible user count leakage by lapinvert · Pull Request #18539 · django/django · GitHub for more details.