Web applications are not static sites but a careful composition of static and dynamic content. More common than not, the web application logic runs in the browser. Instead of fetching all content from the server, the application runs JavaScript in the browser that fetches data from a backend API and updates the web application presentation accordingly.
To protect access to data, organizations should employ OAuth 2.0. With OAuth 2.0, a JavaScript application needs to add an access token to every request to the API. For usability reasons, JavaScript applications usually don’t request the access token on demand, but store it. The question is, how do you obtain such an access token within JavaScript? And when you get one, where should the application store the token so that it can add it to the requests when needed?
This article discusses different storage solutions available in browsers and highlights the security risks associated with each option. After reviewing the threats, it describes a solution in the form of a pattern that provides the best browser security options for JavaScript applications that must integrate with OAuth-protected APIs.
Obtaining Access Tokens
Before an application can store the access token, it needs to obtain one. Current best practices recommend one way to obtain the access token: the code flow. The code flow is a two-step flow that first collects an authorization grant from the user — the authorization code. Then, the application exchanges the authorization code for an access token in a back-channel request. This request is called the token request and is illustrated in the following example:
Note that anyone can inspect resources loaded by the browser, including any JavaScript code. Therefore, any OAuth client implemented in JavaScript is considered a public client — one that cannot keep a secret and, as such, cannot authenticate during the token request. However, Proof Key for Code Exchange (PKCE) provides a means to secure the code flow for public clients. To mitigate risks related to the authorization code, always apply PKCE with the code flow.
Browser Threats
Cross-Site Request Forgery (CSRF)
In cross-site request forgery (CSRF) attacks, malicious actors trick the user into unintentionally performing a malicious request through the browser. For example, attackers may embed a crafted image src string in a website that triggers the browser to run a GET request or add a form on a malicious website that triggers a POST request. In any case, the browser may automatically add cookies to such requests, including single sign-on (SSO) cookies.
CSRF attacks are also known as “session riding,” because attackers typically make use of the user’s authenticated session for their malicious requests. Consequently, attackers can silently perform requests on behalf of the user and call any endpoint that the user can call. Attackers cannot read the response, though, so they usually aim for a one-time, state-changing request, like updating the user’s password.
Cross-Site Scripting (XSS)
Cross-site scripting (XSS) vulnerabilities allow attackers to inject malicious, client-side code into an otherwise-trusted website. Vulnerabilities can, for example, occur at any place in a web application where user input generates output that is not properly sanitized. The browser automatically runs the malicious code in the context of the trusted website.
XSS attacks can be used to steal access and refresh tokens or perform CSRF attacks. There is a time window for XSS attacks, though, because they can only run during a limited period of time, like during the lifetime of a token or as long as the tab with the vulnerability is open.
Even in cases where XSS cannot be used to retrieve access tokens, attackers may exploit XSS vulnerabilities to send authenticated requests to secured web endpoints using session riding. Attackers can then impersonate users, call any backend endpoint that the user can call and cause severe damage.
Storage Solutions in Browser
When the application receives the access token, it needs to store the token to use it within API requests. There are various ways to persist data within a user’s browser. Applications can use dedicated APIs, such as the Web Storage API or IndexedDB, to store tokens. Applications can also simply keep the token in memory or put them in cookies. Some storage mechanisms are persistent, and others are wiped after some period of time or when the page is closed or refreshed.
Some solutions share the data across tabs, whereas others are local to the tab only. However, most methods presented in this guide store data per origin. Therefore, it is beneficial for any related discussion to understand some concepts: origin and site.
The origin of some (web) resource is the scheme, hostname and port of its URL. For example, both https://example.com/number/one
and https://example.com:80/path/two
have the same origin because they share the scheme (https) and hostname (example.com) as well as the port (default port). Their origin is https://example.com
, which is different from https://example.com:8443
or https://this.example.com
because they differ in the port and hostname.
In comparison, a site is bigger than a resource’s origin. A site is the common name of the web application that serves a collection of resources. Simply put, a site is the scheme and domain name, such as https://example.com
. While https://example.com
and https://this.example.com:8443
have different origins (different hostname and port), they are the same site because they are hosted on the same domain (example.com) and use the same scheme (https). (Technically, there are nuances to this definition, but this simplified statement helps to explain the concept).
Local Storage
Local storage is accessed via the Web Storage API using the global localStorage
object in JavaScript. Data stored in local storage is available across browser tabs and sessions, meaning it does not expire nor gets deleted when the browser is closed. Consequently, data stored via localStorage is accessible in all tabs of an application. Therefore, it’s tempting to store tokens in local storage.
Whenever the application calls the API, it fetches the token from the storage and adds it manually to the request. However, since local storage is available via JavaScript, it means that this solution is also vulnerable to cross-site-scripting attacks (XSS).
If you use localStorage for persisting access tokens and an attacker manages to run foreign JavaScript code within your application, the attacker can exfiltrate any tokens and call APIs directly. Moreover, XSS also allows attackers to manipulate data in the local storage of the application, meaning attackers can change the token.
Note that data in local storage is stored permanently, which means that any tokens stored there reside on the file system of the user’s device (laptop, computer, mobile or other) and are accessible to other applications even after the browser is closed. Consequently, when using localStorage, take endpoint security into account. Consider and protect against attack vectors outside the browser, like malware, stolen devices or disks.
Based on the discussion above, follow this advice:
- Do not store sensitive data like tokens in local storage.
- Do not trust data in local storage (especially not for authentication and authorization).
Session Storage
Session storage is another storage mechanism provided by the Web Storage API. Unlike local storage, any data stored using the sessionStorage
object gets wiped when the tab or browser is closed. Also, data stored in session storage is not accessible in other tabs. Only JavaScript code in the current tab and origin can read and write using the same session storage.
Session storage can be considered more secure than local storage because the browser will remove any tokens automatically when the window is closed, so no tokens are left at rest. Moreover, since session storage is not shared between tabs, an attacker cannot read tokens from another tab (or window), which reduces the impact of an XSS attack.
In practice, the main security concern when using sessionStorage to store tokens is XSS. If your application is vulnerable to XSS, attackers can exfiltrate the token from the storage and replay it in API calls. Consequently, session storage is not suitable for storing sensitive data such as tokens.
IndexedDB
IndexedDB is short for Indexed Database API. It is an API for storing larger amounts of data in the browser asynchronously. However, when storing tokens, the features and capacities provided by this browser API are typically not needed. Since applications send tokens with every API call, it is good practice to keep their size to a minimum.
As with other client-side storage mechanisms discussed so far, access to data stored using the Indexed Database API is restricted by a same-origin policy. Only resources and service workers of the same origin can access the data. From a security point of view, IndexedDB is comparable to local storage:
- Tokens may leak through the file system.
- Tokens may leak through an XSS attack.
Therefore, do not store access tokens or other sensitive data in IndexedDB. IndexedDB is more suitable for data required for an app to work offline, such as images.
In Memory
A pretty secure method to store a token is to keep it in memory. Compared to other methods, the token is not stored in the file system, and thus the risks concerning the file system of the device are mitigated.
Best practices recommend keeping the token in a closure when storing it in memory. For example, you can define a separate method to call the API with a token. It does not reveal the token to the main application (main thread). The abstract below shows an example of how to handle tokens in memory with JavaScript.
Note that an attacker may not have access to the token directly after it is obtained and thus may be unable to call APIs directly with the token. Even so, they may call the API at any time via the apiClient
that holds a reference to the token. However, any such attack is restricted to the time period under which the tab is open and the functions provided by the interface.
Besides the security concerns related to potential XSS vulnerabilities, keeping the token in memory has a big downside regarding user experience as the token gets dropped on page reloads. The application must then obtain a new token, which may trigger a new user authentication. A secure design should take user experience into account.
An architecture that makes use of a service worker mitigates usability concerns by running the token handler functionality in a separate thread that is detached from the main web page. Service workers effectively act as a proxy between the application, the browser and the network. As such, they can intercept requests and responses, such as to cache data and enable offline access, or obtain and add tokens.
When using JavaScript closures or service workers to handle tokens and API requests, XSS attacks may target the OAuth flow, like the callback or silent flow, to get ahold of a token. They may unregister and circumvent any service worker, or use prototype pollution to “read the token on the fly” by overwriting methods like window.fetch
. Therefore, consider JavaScript closures and service workers for convenience but not security.
Cookies
Cookies are pieces of data that are stored in the browser. By design, the browser adds cookies to every request to the server. Therefore, an application must use cookies with caution. If not configured carefully, a browser may append cookies with cross-site requests and allow for cross-site request forgery (CSRF) attacks.
Cookies have attributes that control their security properties. For example, the SameSite attribute can help to mitigate the risk of CSRF attacks. When a cookie has the SameSite attribute set to Strict
, the browser adds it to requests that originate from and target the same site as the site of the cookie’s origin. The browser will not add cookies when requests are embedded in any third-party site, like via links.
You can set and retrieve cookies via JavaScript. However, when using JavaScript to read cookies, the application becomes vulnerable to XSS (in addition to CSRF). Therefore, the preferred option is to have a backend component that sets the cookie and marks it as HttpOnly
. That flag mitigates leaking data through XSS attacks because it indicates to the browser that the cookie must not be available via JavaScript.
To prevent cookies from leaking via a man-in-the-middle attack, which may lead to session hijacking, cookies should only be sent over encrypted connections (HTTPS). To instruct the browser to only send cookies in HTTPS requests, a cookie must have the Secure attribute set.
Set-Cookie:token=myvalue;SameSite=Strict;Secure;HttpOnly
As with any other permanent storing solution in browsers, cookies may reside on the file system even after the browser is closed (for example, cookies do not have to expire, or browsers may keep session cookies as part of restore-session-features). To mitigate the risk of exfiltrating tokens from the file system, only store encrypted tokens in cookies. Therefore, the backend component must only return encrypted tokens in the Set-Cookie header.
Threat Matrix
The following table summarizes the threat assessment of storage solutions in the browser, with primary threat vectors marked in red. Orange threats require mitigation beyond what web technologies can offer. Green threats are or can be eliminated successfully using proper settings.
Whenever an attacker manages to steal tokens, they can use the access tokens as long as it is valid independently from the user and application. If attackers manage to exfiltrate a refresh token, they can prolong the attack significantly and increase the damage since they can renew access tokens. Hackers may even extend the attack to APIs other than the ones used by the JavaScript application. Attackers can, for example, try to replay access tokens and exploit vulnerabilities in different APIs.
Stolen access tokens can result in significant damage, and XSS remains a primary concern for web applications. Therefore, avoid storing access tokens in places where they are accessible to client code. Instead, store access tokens in cookies. When configured with the appropriate attributes, there is no risk for browsers to leak the access token through the cookie. An XSS exploit is then comparable to a session-riding exploit on the same site.
OAuth Semantics with Cookies
Cookies are still the best option to transport tokens and act as API credentials because attackers will not be able to retrieve the access token from cookies even if they succeed in exploiting an XSS vulnerability. However, for this to be true, cookies must be properly configured.
First of all, mark cookies as HttpOnly
so that they are not available via JavaScript to address the risk of XSS attacks. Another essential attribute is the Secure flag that ensures the cookie is only sent over HTTPS to mitigate man-in-the-middle attacks.
Second, issue short-lived access tokens that are only valid for a couple of minutes. In the worst case, access tokens with a minimal lifetime can only be misused for an acceptable short period of time. A validity time of 15 minutes is commonly considered suitable. Let the cookie and token expire at about the same time.
Third, consider tokens to be sensitive data. Only store encrypted tokens in cookies. Should attackers manage to get hold of an encrypted token, they won’t be able to parse any data from it. Neither will attackers be able to replay the encrypted token to any other API, as other APIs won’t be able to decrypt the token. Encrypted tokens simply limit the impact of stolen tokens.
Fourth, be restrictive on when to send API credentials. Send cookies only to resources that require API credentials. This means ensuring that the browser only adds cookies to API calls that actually require an access token. For that, cookies need to have appropriate settings, like SameSite=Strict
, a domain attribute that points to the API endpoint’s domain and a path.
Finally, when using refresh tokens, make sure to store them in their own cookies. There is no need to send them with every API request, so ensure that this is not the case. Refresh tokens must only be added when refreshing expired access tokens. That means cookies holding refresh tokens have slightly different settings than cookies with access tokens.
The Token Handler Pattern
The token handler pattern is a design pattern that incorporates best practice principles for OAuth in JavaScript clients. It follows the approach of a backend for frontend (BFF), as described in OAuth 2.0 for Browser-Based Apps. The pattern introduces a backend component capable of issuing cookies with encrypted tokens and the necessary attributes, as mentioned above.
The responsibilities for the backend component are:
- Interacting with the authorization server as an OAuth client to initiate user authentication and obtain tokens.
- Managing tokens for the JavaScript application, keeping them inaccessible.
- Proxying and intercepting all API requests to attach the correct access tokens.
The token handler pattern defines a BFF that abstracts OAuth for applications running in browsers. In other words, the token handler pattern suggests an API that JavaScript applications can use to authenticate users and securely make authenticated calls to APIs. For that, the pattern uses cookies to store and send access tokens.
The Token Handler is a backend component that can, for example, reside in an API gateway. It consists of two parts:
- The OAuth Agent, which handles the OAuth flow to obtain tokens from the authorization server.
- The OAuth Proxy, which intercepts all requests to the APIs and translates cookies to tokens.
After the OAuth Agent gets the tokens, it issues cookies with the following attributes:
- SameSite=Strict
- HttpOnly
- Secure
- Path for the API
Since the Token Handler is a backend component, the OAuth Agent is a confidential client that can authenticate toward the authorization server (compared to JavaScript clients that are public clients). This means that to obtain a token, the OAuth Agent needs to authenticate. Consequently, attackers need to get hold of the client credentials to successfully obtain new tokens. Running a silent flow in JavaScript without the client credentials will fail.
For the token handler pattern to work, the JavaScript application and Token Handler components must be deployed on the same site (in other words, they must run in the same domain). Otherwise, the browser will not add the token cookie to the API requests because of the same-site restriction on the cookie.
To fetch data, the JavaScript application simply calls APIs via the OAuth Proxy:
The browser automatically adds the cookies to the request. In the example above, the browser includes cookies in cross-origin requests. However, because of the cookie attribute SameSite=Strict
, the browser will only add the cookies to cross-origin requests at the same site (same domain).
The OAuth Proxy decrypts the cookies and adds the token to the upstream API. The cookie attributes ensure that the browser only adds cookies to HTTPS requests, ensuring they are secure in transit. Since tokens are encrypted, they are also secured at rest. Tokens are then used to get secure access to APIs.
Conclusion
API access is best secured using OAuth and access tokens. However, JavaScript applications are behind the eight ball. There is no secure solution to store tokens in the browser. All available solutions are, to some extent, vulnerable to XSS. Therefore, the first priority of securing any application should be to prevent XSS vulnerabilities.
The token handler pattern mitigates XSS risks by storing encrypted tokens in cookies that are unavailable to any JavaScript. It separates web concerns from API concerns and provides guidance for hardening JavaScript applications with well-established web technologies without compromising web architecture. Check out the detailed description of the token handler pattern and explore the various examples.