When getting into software development, one is admonished not to implement authentication oneself but rather to use a web framework with a robust and tested auth system, such as Django, ASP.NET or Ruby on Rails. I generally follow this advice, but I try to understand how the tools I use work to prevent common classes of attacks, such as cross site request forgery (CSRF/XSRF), cross site scripting (XSS). The first web framework I learned, Django, had mitigations in place for many common vulnerabilities, such as anti-CSRF tokens that the framework issued and verified behind the scenes for every form submission, and I felt confident in the security of my projects. But then I learned about JSON APIs and Single Page Applications (SPAs) and that’s when the trouble started.

In fact, I was still using Django when I first encountered the issue I’ve come to refer to as “SPAuthentication”, but this time I was using Django Rest Framework (DRF) to build JSON API endpoints rather than server rendered pages. The challenge was authenticating from the javascript frontend I built, and keeping the user logged in. Because it was a simplest option mentioned in the DRF documentation, I used JWTs as bearer tokens and stored them in localStorage. I’d never had to think about client-side storage of session or auth tokens before; I didn’t see a better way to handle it, but this was my first inclination that something was wrong. Work took me in a different direction, namely server side rendering, which made authentication relatively simple: I continued to use the built-in authentication in .NET Core, Django, and Phoenix. But my curiosity would push me again to grapple with “SPAuthentication”.

I stumbled across PostgREST and it piqued my curiosity. I love relational databases, and I got very excited by the idea of writing more SQL and less boilerplate application code, especially serialization and ORM code. I immediately tried to use PostgREST but found myself once again frustrated by authentication, and this time I wasn’t happy with a toy solution that wasn’t production ready; I wanted to be able to build real products with PostgREST. I read from multiple sources (1, 2) that storing JWTs in localStorage on the web was not a good idea, so I wasn’t going to do that again. Fortunately, a startup called Supabase was excited about PostgREST too, in fact they built their company on top of it. They had already implemented an auth system, and since it was open source, I figured I would either self-host Supabase or just look at their code to see how they handled authentication. To my dismay, I saw that their Javascript client stores access and refresh tokens in localStorage. I searched the GitHub issues to see if anyone else had noticed this and raised a concern. A few people had, and the Supabase response to these concerns is disappointing.

In a discussion about XSS on GitHub, a Supabase contributor says, “…there’s no way to “secure” (sic) content stored in your browser from other libraries you include in your app.” But there is a way, that all browsers support: HttpOnly cookies:

“A cookie with the HttpOnly attribute is inaccessible to the JavaScript Document.cookie API; it’s only sent to the server. For example, cookies that persist in server-side sessions don’t need to be available to JavaScript and should have the HttpOnly attribute. This precaution helps mitigate cross-site scripting (XSS) attacks.” (MDN web docs)

In a response to an issue opened in 2021, a Supabase contributor makes the claim that “Local storage is equivalent (and more secure) than storing tokens in cookies.” OWASP doesn’t seem to agree. In fact, OWASP has an extensive guide to using JWTs securely, and it’s so complex that it negates any ease of use initially gained by adopting JWTs; and you can forget about statelessness if you want to be able to log users out or revoke their access.

This isn’t a problem unique to Supabase. I researched similar services at the time and from what I could tell the Firebase JS SDK also stored access and refresh tokens in indexedDB or localStorage on web, though this may have changed in the two years since.

These may be relatively minor security vulnerabilities, but here’s why they chafe: Firebase and Supabase are generic products designed to serve broad use cases and save developers the work of implementing backend code, and by having “good enough” security (with no apparent intention to overhaul it), they send the message to developers like me, “don’t roll your own auth; don’t even implement your own backend; pay us for a solution that’s quick to set up, just don’t look too closely because we don’t follow industry best practices.”


Closing Thoughts

  • For something I’m not supposed to implement myself, I spend an awful lot of energy on auth. Since almost every project needs it, relying on existing, tested solutions saves me a lot of time when possible - I wish there were solutions I trusted for SPAs.
  • Browser and auth conventions have not caught up to SPAs, so bad practices abound.
  • Personally, I favor batteries-included server-side rendered frameworks for web projects requiring auth because they have battle-tested authentication that conforms to best practices (as far as I can tell).
  • It’s been said before but once you actually implement all the mitigations to make JWT authentication secure, they’re no longer stateless or easy to use. I therefore see no advantage to using JWTs for sessions.
  • While writing this I discovered that somebody is working on session-based authentication with PostgREST, which I will definitely investigate further.