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.
HTTP cookies
In a traditional server-client web architecture, the server can set a cookie in the HTTP response header, and the browser automatically attaches it with every request, creating a session between the client and server. There is no need to store the secret locally in the browser. Furthermore, there is HttpOnly flag, which blocks accessing the cookie from JavaScript. Combined with the SameSite attribute, only the target server will have access to the cookie, effectively isolating it. There is also the Secure attribute which is only sent to the server over the HTTPS protocol.
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)
The general idea is to trick the user into performing an unwanted action. For instance, the user is authenticated with bank.com by a cookie. The attacker uses a phishing email to send the user a malicious link to a web page with a form sending request to bank.com. The user either clicks on the submit button or the form is submitted by JavaScript. Because the user is authenticated with bank.com, the server accepts this unwanted request. Again, OWASP materials do an excellent job of explaining it.
Security aspects
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.
Storing a secret in the browser is susceptible to XSS because any JavaScript on the page can access it. Storing it in memory might be better, but the secret can still be compromised. Web Workers effectively isolate the storage context, though the secret is indirectly accessible by messaging with the worker.
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';
Cookies are generally considered the more secure approach (XSS is on the 3rd place in OWASP Top Ten list), but only with HttpOnly, SameSite and Secureattributes. That way, the cookie cannot be accessed by JavaScript or sent from/to an unauthorized origin. To add another level of protection against CSRF attacks, we can set CSRF tokens to all forms on the web and verify them on the server (frameworks should usually do this automatically).
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.
Scalability
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.
Takeaways
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?