How to securely implement authentication in Single Page Applications (SPAs) with a decoupled API
Asked Answered
M

3

13

I have been researching how best to store authentication tokens in a Single Page Application (SPA). There is some existing debate about this topic on SO but as far as I can see, none offer concrete solutions.

Having spent much of yesterday and today trawling the internet for answers, I came across the following:

  • Local Storage API. I found that some basic guides suggest the use of localStorage (though many rightfully advise against it). I do not like this approach because data stored in localStorage could be accessed in the event of an XSS attack.

  • Web Workers. If the token is stored in a web worker, the user will not be logged in if a new tab is opened. This makes for a substandard and confusing user experience.

  • Closures. Same as Web Workers - there is no persistence.

  • HttpOnly Cookies. On the one hand, I read that this can protect from XSS. However, on the other hand, wouldn't this mean that we now have to deal with CSRF? Then it's a new debate altogether: how does one implement CSRF tokens with an SPA + REST API?

How is everyone else doing it?

Miracle answered 2/4, 2022 at 23:22 Comment(0)
H
13

I'm happy that you're asking this question. There are recurring memes regarding oauth2 on the frontend that are really polluting the debate, and finding factual information is difficult.

First, regarding some excluded options which I suggest reconsidering: if you need the same authentication on multiple tabs, you can still use any option that would store tokens in a window scope, but individually manage tokens and get a new one on page refresh (silent refresh, thus standard prompt=none flow). This opens some options: service worker, web workers, closures... True, some of this isn't meant for that originally, but it solves the problem nicely. This also solves a bunch of race conditions about refresh tokens (they can only be used once, so having one for each tab solves a bunch of problems).

That being said, here are the options:

  • Local storage: in case of successful XSS attacks, tokens can be stolen. XSS=game over anyway IMO (no hacker will care about your token in such a case, it's not needed). It can also be mitigated by having short-lived tokens in comparison with the typicial hours/days validity of cookies. In any case, short-lived tokens are recommended.

    Now, stolen tokens in case of XSS seem to be an important issue for some people, so let's look at the other options anyway.

  • Session storage: same downsides as local storage (XSS can lead to session leakage), introduces its own CSRF issues, but also solves some others (refresh...).

  • Web workers: this is actually a nice solution. In case of successful XSS in a random part of the application, it won't be able to steal tokens. In theory, if one could inject some script that would run at authentication (auth code or token exchange), it could be exploited too... but that's true for all flows, including cookies/sessions.

  • Closures: same as web workers. Less isolated (easier to replace by one that would steal your token).

  • Service worker: ideal solution in my opinion. Easy to implement (you can just intercept fetch requests and add your token in a few lines code). Can't be injected by XSS. You could even kind of argue that it's actually meant for that exact use case. It also solves the case of multiple applications on a page (they share 1 service worker which adds token when required), which none of the other options solves nicely. Only downside: browser can terminate it, you need to implement something to extend it's lifetime (but there is a documented, standard way).

  • HttpOnly Cookies: in short, you're then transitioning to a traditional server-side web application (at least for some parts), it's not an independent SPA with standard oidc or aouth2 anymore. It's a choice (it's not been mine for some years now), but it shouldn't be motivated by token storage, as there are options for that which are even secure and arguably better.

Conclusion: my recommendation is to just use local/session storage. Successful XSS will probably cost you your job or your customer anyway (hint: nobody is interested in your tokens when they can call the pay(5000000, lulzsecAccount) API).

If you're picky about token storage, service worker is the best choice IMO.

Haldes answered 10/6, 2022 at 9:30 Comment(4)
Hi @ymajoros, about service workers you say "Only downside: browser can terminate it, you need to implement something to extend it's lifetime (but there is a documented, standard way)." what is this documented way? In W3C definition (w3.org/TR/service-workers/#service-worker-lifetime) say "The lifetime of a service worker is tied to the execution lifetime of events and not references held by service worker clients to the ServiceWorker object." it is posible to avoid this? im thinking in store a refresh token in the SW.Ceceliacecil
@Ceceliacecil you can send it a keepalive event while the app is running. After the tab is closed, it will eventually be killed by the browser. As such, I wouldn't keep a refresh token there, as there is no documented mechanism for specific safe long term storage in a service worker. Of course, you can also just implement a "silent refresh" flow (see prompt=none) and get a fresh token anyway on application start if you deserve so.Haldes
I saw web workers (and closures) recently on an auth0 page advocating to use this architecture for securing tokens. It would seem this only protects against an attacker using XSS to exfiltrate an auth token. But they (the attacker) could just perform authenticated API calls without knowing the token (if e.g. a web or service worker intercept is being used to insert the token). What’s the motivation for extracting and sending off a token anyway? It seems like more workTomy
@Tomy you're quite right, actually. XSS is the end of the game anyway. Token exfiltration after that horror scenario seems to still matter to some people, and that's basically the reason why SW were introduced. OAuth web worker thingy is interesting, but can be easily bypassed: as an attacker, just stop the damn worker, initiate a new auth flow, get fresh tokens silentlyHaldes
P
4

The solution is a HttpOnly SameSite Cookie.

In the question, you correctly note that HttpOnly protects from XSS. SameSite in turn protects from CSRF. Both options are supported by all modern browsers.

This is orders of magnitude simpler and safer than other solutions. It's easy to set up on the API and completely transparent to the SPA. Security is built-in.

Concrete solution: The actual authentication can be done by your API or by an external provider that redirects to your API. Then, when logged-in, your API creates a JWT token and stores it in a HttpOnly SameSite Cookie. You can see this at work with NestJS at nestjs-starter as explained in: OAuth2 in NestJS for Social Login (Google, Facebook, Twitter, etc).

One limitation is that API and SPA have to be on the same domains.

For rather storing the token client-side, this other article is very comprehensive.

Physicalism answered 30/9, 2022 at 13:27 Comment(5)
Im new to web dev but have been wondering why I don’t see more cookie based auth (rather than tokens). If you have auth cookie tokens stored in the browser then your app need only query some endpoint (e.g. /api/users/whoami or so) and have the backend tell your frontend if the users auth tokens are still valid (or perhaps issue new ones if the refresh token is still valid). The only benefit I see to tokens is for an API that may be consumed by programs (not a browser), or if you really just wanted to avoid the initial web request on first render of frontend. Am I missing something?Tomy
@Tomy You're right but also, before HttpOnly and SameSite cookies were widely supported by browsers, cookie auth had security issues.Physicalism
Why is the Authorization header so common these days? This implies the need to store the token in some way (e.g. localStorage if you want persistence in the browser). Meanwhile, you can have the HttpOnly/SameSite cookies and implicitly handle auth without really managing the cookies/tokens yourself. The use-case for tokens I feel should be limited to the "non-browser" case wherein token security is an entirely different matter in a completely different environment. I don't understand why we migrated to expecting the frontend to manage tokens (esp. with HttpOnly/SameSite now).Tomy
To be clear, I like JWT. I am wondering what the overall benefit of using JWT with an Authorization header explicitly in the frontend is and why it has been widely adopted over just putting that same JWT into a HttpOnly/SameSite/Secure cookie and letting the browser and backend mostly handle auth and leave the frontend end a bit simpler.Tomy
Multiple benefits, among which: frontend initiates the authentication whenever it wants (e.g. some parts of a website, or just optionally), with the parameters it wants (different scopes based on some other decisions, ...). Also, this is a standard way (OAuth) of handling security between frontend and backends, and not having a custom authentication flow. Also: a cookie is always passed, which opens some new attack vectors. And finally, chances are nowadays that you'll have the same endpoint for your mobile application anyway, so it makes additional sense to protect them in the same way.Haldes
U
1

Your comparison is a bit confusing... When you talk about SPA authentication, you need a solution that alows you to store authentication tokens. The main purpose of Web Workers and Closures is to run code, not for to store authentication tokens. You can write it down as a hard coded variable but it's not its purpose and this is why a new tab won't identify it.

So we're left with Local Storage and Session Cookies. Before SPA and client side rendering, we used to only have server side rendering and cookies. This is when HTTPOnly was invented to make it harder to steal session IDs and users' identities. When client side rendering was invented, stateless APIs were invented. These APIs do not use session IDs but Tokens instead, such as JWT. It means that the server do not save any information about the user in a session that is identified by a session ID. Instead, JWT tokens contains the signed information within the token itself, and the server do not remember anything and reauthenticate the user in each request. There is also a hybrid approach of tokens that are saved in NoSQL DBs in the server side such as Redis, to make the authentication process faster.

But the bottom line is that HTTPOnly cookies are used with stateful APIs that use sessions, and LocalStorage are used with stateless APIs that use tokens. You can also use it the other way around (cookies with tokens, LocalStorage with sessions) but its "less natural".

As for XSS, HttpOnly mechanism in cookies makes attackers' life a bit harder, but even if it is used, attackers can still do a lot of damage with phishing for example. So its not critical as this is just a compensating control and not a main mitigation in any way, so you can safely use LocalStorage. Furthermore, cookies are prone to CSRF attacks where LocalStorage does not. So both options are valid.

You can read about it some more in here: What is the difference between localStorage, sessionStorage, session and cookies?

Unblinking answered 3/4, 2022 at 9:18 Comment(2)
If you need the same authentication on multiple tabs, you can still use any option that would store the token in a window scope, but individually manage tokens and get a new one on page refresh (silent refresh, prompt=none flow). This opens some options: service worker, web workers, closures (yes, some of this isn't meant for that originally but can solve the problem in a nice way). This also solves a bunch of race conditions about refresh tokens (they can only be used, so having one for each tab solves a bunch of problems).Haldes
OP may have found this description from Auth0 that suggests to store tokens in closures inside web workers. Indeed it does protect against an XSS attacker from exfiltrating the token, but they can still use it while executing arbitrary js in the browser anyway (by asking the web worker to make some web request, which would insert the token into the request). So I don’t really understand the motivation for this.Tomy

© 2022 - 2024 — McMap. All rights reserved.