Back Original

CSRF protection without tokens or hidden form fields

A couple of months ago, I received a request from a random Internet user to add CSRF protection to my little web framework Microdot, and I thought it was a fantastic idea.

When I set off to do this work in early November I expected I was going to have to deal with anti-CSRF tokens, double-submit cookies and hidden form fields, pretty much the traditional elements that we have used to build a defense against CSRF for years. And I did start along this tedious route. But then I bumped into a new way some people are dealing with CSRF attacks that is way simpler, which I describe below.

Implementing a security feature

An often shared piece of advice is that you should never implement security features yourself. Instead, you should look for well established solutions built by people who think about security day in and day out.

Unfortunately, as the lead (and only) maintainer of Microdot, I do not have an ecosystem of existing solutions available to me. Even though I gladly accept external contributions, most of the framework has been built by myself out of nothing. So in this case, like many other times before, I felt I had no choice but to go against the standard advice and write CSRF protection code by myself, because if I didn't do it then the feature would not be built.

What is the first step when you need to build a security feature? Check out what OWASP has to say about the matter.

So, in early November, I opened OWASP's CSRF Prevention Cheat Sheet page to see what was new and interesting in the world of CSRF protection. And I found that nothing of significance had changed.

According to OWASP, the best CSRF protection you could get (at the time I checked) was still built around the idea of using anti-CSRF tokens. So I set off to implement this for Microdot.

A disturbance in the (CSRF) force

I was happily making progress on my CSRF implementation, and then in early December, another random Internet user dropped an issue on the Flask repository, proposing that Flask adds support for "modern" CSRF protection. Modern? How could there be a new way to protect against CSRF that isn't mentioned by OWASP?

This led me down a rabbit hole of blog posts and discussions spanning the Go and Ruby communities, plus a long discussion about this method on the OWASP GitHub repository itself, resulting in a pull request that added a mention of this method to the CSRF Cheat Sheet, only a couple of weeks after I went to this page looking for guidance for my own implementation.

Modern CSRF Protection

The so called "modern" method to protect against CSRF attacks is based on the Sec-Fetch-Site header, which all modern desktop and mobile browsers include in the requests they send to servers. According to Mozilla, all browsers released since March 2023 have support for this header.

The Sec-Fetch-Site header can have one of four values:

The value of this header cannot be set via JavaScript, so the server can assume that a) if this header is present, then the client is a web browser, and b) the value of the header can be trusted. So basically, the server can reject requests that come with this header set to cross-site, and in essence that is all you need to do to protect against CSRF!

After seeing this, I paused my work on the token-based CSRF implementation and spent a few hours to implement this modern approach. As always, the devil is in the details, so let's see what else I needed to do to build a complete solution.

First of all, in some cases subdomains sharing the same registered domain may operate independently, and as such, it is not out of the question that one subdomain may attempt to attack another through CSRF. Depending on the level of trust an application has for other subdomains, a server may want to block requests that come with the Sec-Fetch-Site header set to same-site. In Microdot, I have added an argument allow_subdomains to cover this case. I decided to err on the side of security, so the default is False, meaning that requests from subdomains are also blocked.

The other big problem is that not everyone is using a recent browser that implements this header. Looking at the browser compatibility for the Sec-Fetch-Site header, you can see that most browsers implemented this feature long ago, between 2019 and 2021, with one notable exception: Safari. Apple added this header to its browser in 2023, so it is reasonable to assume that there are still users out there running older browsers that do not support it.

One option is to reject all requests that do not have the Sec-Fetch-Site header. This keeps everyone secure, but of course, there's going to be some unhappy users of old devices that will not be able to use your application. Plus, this would also reject HTTP clients that are not browsers. If this is not a problem for your use case, then great, but it isn't a good solution overall.

From what I gathered from looking at other implementations of this method, an accepted solution is to use the Origin header as fallback when Sec-Fetch-Site is not implemented, since this header has been around for much longer. The last of the major browsers to add it were Firefox desktop in 2019 and Edge and Firefox mobile in 2020. Like Sec-Fetch-Site, the Origin header is also a restricted header that is set by the browser, so it can also be used to determine from where a request is coming from.

The problem with using the Origin header is that it isn't always easy to know what is the correct origin that applies to a web application. The standard option is to compare the value of the Origin header against the value of the Host header, but Host only includes the hostname and port, while Origin also includes the scheme. Also, the Host header is overwritten as it passes through reverse proxies. So comparing these two headers is actually not easy.

Another, more direct option is to ask the user to configure the expected origin name explicitly. To keep things simple, in Microdot I opted for the explicit configuration, for which I linked to the existing Cross-Origin Request Sharing (CORS) support. The CORS feature already maintains a list of allowed origins, so my CSRF logic automatically trusts these. I decided to not complicate myself adding support for Host header checks at this time, but maybe I'll add this in the future.

Filippo Valsorda, a security developer active in the Go ecosystem (and author of the popular mkcert tool) wrote a blog post about this method that you may want to check out if you want to learn more details about it. He seems to be the first to propose this method and has implemented it for the Go standard library.

Also if you are interested, feel free to review my implementation of CSRF protection in Microdot. Have a look at the documentation, the code and an example, and let me know if you have any improvements or fixes to suggest.

Let's revisit OWASP

Note: this section is now out of date. As of December 24th 2025 the OWASP CSRF Cheat Sheet page lists the Fetch Metadata method as a complete solution that can be used as an alternative to token-based approaches.

As I mentioned above, the CSRF Prevention Cheat Sheet page from OWASP was updated in early December to include the use of the Sec-Fetch-Site header in the list of prevention methods. But this method is currently listed as a defense in depth mechanism, and not a complete solution, which I thought was odd.

I referenced the discussion in the OWASP GitHub repository that resulted in the recent changes made to the Cheat Sheet page. Several participants in that discussion have suggested that this method should be upgraded to a complete alternative to the standard token-based approaches. The OWASP maintainer was initially skeptical, but towards the end of the thread they have agreed. The pull request that closed the discussion added this solution as an alternative to the token-based approaches, but then a later change made significant updates, including the downgrade to defense in depth. My hope is that this is just a misunderstanding, and that the OWASP folks will restore the content as it was agreed by all the parties involved.

In any case, I consider that in Microdot, going from no CSRF support at all to this is a great step forward that is also consistent with the minimalist ethos of the project. I will be keeping an eye on the OWASP CSRF Cheat Sheet page to see what is their final word on this new protection method, and if they end up keeping it as defense in depth, I still have a mostly complete implementation of double-submit anti-CSRF tokens that I can bring into my project.

Conclusion

What I like the most about working in open source is that all the work happens in the open, so it is a permanent record that can be searched and reviewed. My CSRF protection journey started as a somewhat tedious exercise in the use of cryptography and cookies, but then thanks to an unexpected lead it turned into a fun and exciting learning opportunity for me.

Thank you for visiting my blog! If you enjoyed this article, please consider supporting my work and keeping me caffeinated with a small one-time donation through Buy me a coffee. Thanks!