Should I Store Access Tokens in Browser Storage?
Security discussion, alternatives, and scalability
The popularization of Single Page Applications in recent years required new ways of authentication compared to traditional server-client websites. With the rise of frameworks like Next or Remix, we've been slowly moving forward to the original architecture with some enhancements. It could be confusing, especially to front-end development newcomers, what are the authentication options and their security and scalability tradeoffs. I have struggled sometimes to understand these concepts, therefore I'd like to summarize and discuss them in this post.
Means of authentication on the web
There are two common ways of authenticating a front-end client with a server.
Authorization HTTP header
The frontend sends the secret in Authorization header (or query parameter/request body) with every request to the server but needs to store the secret token somewhere after the user signs in. With the widespread Single Page Application(SPA) architecture, the typical solution is to store the secret in browser storage, be it local / session storage or IndexedDB, which all have the same properties security-wise. There is also an option to store the secret only in memory, but the user loses the session after a page refresh.
Another progressive approach suggests storing the secret in a Web Worker to isolate the context for the secret. A popular authentication library Auth0 implements this pattern in its SDK, and Firebase also suggests managing client-side authentication in Service Workers.
Session hijacking attacks
Let's briefly look at the possible attack vectors for compromising secrets. I'll discuss possible defenses later in the article.
Cross-site scripting (XSS)
This is a whole class of attacks in which a malicious script is injected into the web app. There are many ways to execute XSS. For example, unsanitized user input or installing a popular NPM library that gets hacked and mines crypto (or sends our secret to an untrusted server). I'd recommend reading OWASP materials for more information or watching these great Web Security lectures.
Cross-site request forgery (CSRF)
So, an easy question, what is the correct secure solution? Unfortunately, not an easy answer, and more importantly, the wrong question. In security, you can never be sure. You can only mitigate specific attack vectors. How can I protect my frontend against compromising a secret through an XSS vulnerability? That's better.
One way to mitigate some forms of XSS is to configure Content Security Policy, including all essential directives. It is critical to use nonces or hashes for inline scripts. Otherwise, there is not a big difference from not having CSP as most attacks use inline scripts. CSP restricts target origins which the frontend can send requests and load data from, including Fetch API / XHR requests. With the policy set only to trusted servers, the potential attacker can never send the compromised token to his server. I must give credit to my friend Tomáš for pointing CSP out in our discussion about web security.
Content-Security-Policy: default-src 'self' data:; img-src *; object-src 'none'; script-src 'strict-dynamic' 'nonce-VALUE_HERE' * 'unsafe-inline'; style-src 'self' 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'self';
Another advantage is that a browser automatically sends cookies with all requests, so there is no need for additional client-side implementation. Cookies can be only 4 KB large, which might not be enough for some forms of secrets (e.g., enormous JWT token). Also note that all requests from the front-end must be proxied through our backend server, which can be a performance bottleneck in some architectures.
The usage of cookies is a stateful solution in terms of communication with the server, because it needs to remember client sessions. This could be a performance bottleneck for higher traffic loads. Scaling server instances, adding a load balancer, and using sticky sessions solves the issue but adds complexity.
Compared to cookies, using Authorization header is a stateless approach and scales well without additional complexity as the clients call backend APIs directly (in some architectures). That's the reason SPAs have become popular in the first place.
With the growth of edge computing architecture like Cloudflare Workers or Next.js middleware, usage of cookies may scale well without infrastructural complexity.
There is no silver bullet in security, and both authentication options have tradeoffs. HTTP-only same-site cookies are considered the more secure approach though. Browser / in-memory storage with correct CSP configuration may also be a suitable approach that scales well for some use cases. You should also look at the system as a whole. There can be other more severe security issues than a front-end vulnerable to session hijacking with XSS (e.g., hacked NPM library printing .env at the backend).
The rise of Web 3 has brought a new architecture concept employing a wallet in the form of a browser extension, which signs blockchain transactions. Could this be a security progression from the current authentication methods in the future?