Saving tokens securely when using OAuth2 is often mishandled, even in tutorials, so we decided to implement the recommendations from the official specification by ourselves. Let us introduce you to our new open-source library – auth-worker.
OAuth2 is a protocol widely used in modern web and mobile applications, enabling seamless integration with third-party services. You must have seen web pages or apps that have a “login with Google/Facebook/Twitter/other service” option – this functionality is built upon the OAuth2 standard.
However, secure token storage with OAuth2 can be a challenge, and all of the tutorials available seem to mishandle the issue.
With OAuth2 specification as the basis, we implemented the official recommendations and created an open-source library that enables secure token storage.
In this blog post, we present both the security problem and the solution, focusing on the implementation of OAuth2 in the browser, and more specifically, in single-page applications.
What is OAuth2?
OAuth2 is an open standard protocol that provides a secure and standardized way for users to grant limited access to their protected resources on one website (the resource server) to another website or application (the client) without having to share their login credentials. It acts as an authorization framework, allowing users to delegate access rights to their resources across websites and applications.
Why use OAuth2?
There are many benefits to using OAuth2. First and foremost, it enhances security by eliminating the need for users to share their usernames and passwords with third-party applications. Instead, OAuth2 relies on authorization tokens, reducing the risk of credential theft and unauthorized access.
This framework also offers fine-grained control over access permissions, allowing users to grant specific rights to different applications. It provides a layer of abstraction, ensuring that users retain control over their data with the option to revoke access at any time.
OAuth2 promotes interoperability and simplifies integration between different platforms, fostering the development of a vibrant ecosystem of interconnected services.
Furter, OAuth2 enhances user experience by enabling the single sign-on (SSO) functionality. Users can authenticate once with the identity provider (such as Google or Facebook) and then seamlessly access multiple applications and services without the need to log in repeatedly. This streamlines the login process, reduces friction, and improves usability across various platforms and devices.
OAuth2 also encourages innovation and collaboration by facilitating the integration of third-party services, allowing developers to leverage existing platforms and extend the functionality of their applications without reinventing the wheel.
What the tutorials get wrong
If you search for tutorials on how to implement OAuth2 in a JavaScript application, almost every tutorial or article will make one crucial mistake – how to save tokens.
The most obvious way to save data in the browser is to simply save it to localStorage. If you are trying to be more conscious of security, you might decide that sessionStorage is better, or perhaps cookies. However, out of those options, only one is secure enough to store credentials, and only under specific circumstances – http-only cookies
With http-only cookies, the server sets the cookie, and only the server can read it – it is completely invisible to the JS app in the browser. However, this might not always be an option as OAuth2 providers usually don’t support this method.
The problem with localStorage, sessionStorage, and regular cookies (or any other similar storage, like IndexedDB) is that they can be accessed by the JS code and are therefore susceptible to XSS attacks.
There are multiple real scenarios when this could become an issue:
- A compromised dependency could add some code that steals the tokens
- Some user input might not be escaped well enough, and a malicious user could insert code that would steal the tokens
- If you’re using tools like Google Tag Manager, someone could unknowingly (e.g., while not checking it closely enough) inject a malicious script through the GTM dashboard that steals the tokens
- A rogue browser extension installed in the user’s browser that is stealing data from browser storage and sending it to its owner
Of course, in some cases, the risk might be acceptable – if it’s just an internal app and all other best practices are followed, and the expiration time of the tokens is short, these security risks might not matter to you. Regardless, this should be an educated decision with known risks.
So, what is the solution?
Surprisingly, despite many tutorials mishandling this issue, the OAuth2 specification actually has some good recommendations on how to solve this issue.
Apps with server-side rendering
In many cases, when we’re using React, we also use Next.js, which basically gives us a lot of flexibility. In this case, the simplest solution is to use the Backend for Frontend Proxy with http-only cookies by using the Next.js API endpoints as a proxy for all authenticated API calls. We also combine this with NextAuth.js, which handles the login and token exchange in a secure way (on the server side).
Client-side rendered apps
If you don’t have a dedicated server, things are a bit trickier. To handle tokens in a secure way (and not expose them in the browser scope), the specification suggests leveraging Service Worker.
The OAuth2 specification recommends using Service Workers because they can intercept API calls and add the credentials before passing the API call to the server. They can also intercept the login flow – that way, no sensitive data needs to be exposed to the main browser thread.
In this case, one big downside of client-side rendered apps is that there is no secure way to persist the tokens in the browser because all storage options are available across all scopes. This means that the tokens exist only in the service worker’s memory. Since Service Workers usually have a longer lifespan than the web pages they belong to, the UX will not be significantly compromised. In some cases, an acceptable compromise would be to encrypt the data with a key that is hardcoded in the service worker, but this is more of a security through obscurity solution, and it’s not covered in the best practices document.
And it’s not just the tutorials…
If we look at the OAuth2 libraries and SDKs from some big names like Auth0 and Microsoft, we can see that they make the same mistake – asking the user to detect a callback and then saving the data into the regular cookie or local/session storage. This leads us to wonder why they would not implement the best practice that some of them (e.g., Okta, the owner of Auth0) contributed to. It is possible that this is due to issues with Service Workers:
- Developer Experience while using SW is worse than without them – it is harder to revalidate things, the DevTools are not that polished, etc.
- Browser support – until recently, Service Workers were not a viable solution for general usage. Right now, however, all major browsers (except Firefox in Private Mode) support Service Workers.
OAuth2 implementation security issue – solved
Since there seemed to be no good solution for this problem (at least not one that is provider-agnostic), we decided to make our own – and open-source it.
Meet auth-worker, a library that does most of the heavy lifting of Oauth2 authentication with the workers. It has built-in support for some OAuth2 providers (e.g., Google and Facebook), and anyone can define their custom providers, with new ones to be added in the future.
Auth-worker also manages the PKCE flow and OAuth2 state (both of these are security recommendations from the specification) automatically without any extra work from the app side.
If it is an acceptable compromise for you, the library also supports persisting the encrypted auth data in IndexedDB.
To get started with the library, you just need to do three things:
1
Create a service worker:
// service-worker.ts
import { initAuthServiceWorker } from 'auth-worker/worker';
import { google } from 'auth-worker/providers';
initAuthServiceWorker({ google }, '/auth', ['/allowed', '/routes']);
2
Load the service worker:
// index.ts
import { loadAuthServiceWorker } from 'auth-worker';
loadAuthServiceWorker({
google: {
clientId: 'example-client-id',
scopes: 'https://www.googleapis.com/auth/userinfo.profile',
},
}).catch(console.error);
3
Start using the library.
Here is an example of how to make an authenticated API call:
const response = await fetch('https://example.com/api', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Use-Auth': 'true', // This is how we tell the library that we want auth
},
body: JSON.stringify({
foo: 'bar',
}),
});
Prioritize security with auth-worker
There are a lot of different ways to implement authentication, but all of them have their pros and cons. Depending on your requirements, you might choose a different approach, but it is always important to keep security in mind and make decisions that don’t endanger the safety of users or our data. We hope that you find auth-worker useful in overcoming the token storage and security challenges of OAuth2.