close icon
XSS

Secure Browser Storage: The Facts

Which browser storage options are secure, and which leave your data vulnerable to XSS attacks?

February 01, 2021

I recently gave a talk at OWASP Virtual AppSecIL 2020 on “Security Facts and Fallacies about Browser Storage,” where I presented the different browser storage options and the security guarantees they offer. When talking about browser storage and security, the top 1 concern is an XSS vulnerability, which will allow an attacker to retrieve sensitive data stored in the browser. In this blog, I’ll walk you through all the details I shared during the presentation. We’ll cover different browser sandboxes like origin sandbox, javascript closures, and process sandbox. By the end of the blog, you should have a solid understanding of what are the options and the requirements to build a secure browser storage solution.

Browser Security Basics: The Same Origin Policy

Before diving into the details, it is important to explain a critical security mechanism of the browsers: Same Origin Policy.

In the Same Origin Policy, an origin is defined as a tuple of Protocol, Host, and Port (if specified). If any of the three elements change, the origin changes too. To better understand it, the following table gives examples of origin comparisons to https://auth0.com:443/security.

The Same Origin Policy

Same Origin Policy controls access to data between websites. Specifically, it restricts a document or script loaded from one origin to interact with a resource from another origin. Without it, any web page would access the DOM of the page of another origin!

The following diagram shows the interactions between a browser and different origins.

  1. A user visits a malicious webpage, https://attacker.com.
  2. The web server sends the response back to the browser, which includes HTML and malicious JavaScript.
  3. The malicious JavaScript code sends a request to auth0.com to get the user’s private data. However, the origin of JavaScript is https://attacker.com, which is a different origin than https://auth0.com. As per Same Origin policy, the browser will refuse to receive the response from https://auth0.com.

Get Page With Javascript

Browser Storage Options in Detail

Let’s now talk about each of the following browser storage options:

  1. Local Storage
  2. Session Storage
  3. Cookies
  4. In memory

Local Storage

Local Storage offers isolation per the Same Origin Policy, meaning that one origin cannot access the Local Storage of another Origin. Data stored there is saved across browser sessions, so if a user closes the browser tab or window, the data will still be available in the Local Storage when the user revisits the page.

In terms of security protection against XSS attacks, Local Storage is not effective, as the JavaScript that an attacker injects via XSS runs on the same origin as the rest of the browser application code. An attacker can easily retrieve the value of a secret named secret with a single line:

localStorage.getItem(secret)

Note that the attacker doesn’t need to know the name of the secret, as they can also retrieve all the values stored in Local Storage. Two different ways to achieve this are:

Object.entries(localStorage)

or

for (key of Object.keys(localStorage)) { 
  console.log(key, localStorage.getItem(key))
}

Session Storage

Session storage also offers isolation per Same Origin Policy, but there are some differences compared to Local Storage.

Data stored in Session storage is cleared when a browser session ends. Additionally, data stored in Session Storage is not shared between two different browser tabs or iframes. The latter has an important security benefit considering the following example:

Let’s assume that a user has opened a web page in one browser tab in which a secret is stored in Session Storage. At the same time, the user is tricked by an attacker into following an external link carrying an XSS payload. The external link will open either in the same tab where the link was located or in a new one. In both cases, the tab is different from the initial one and therefore doesn’t have access to the initial tab’s Session Storage. As such, the attacker will not be able to retrieve the secret stored in the Session Storage on the primary tab.

Although this reduces the attack surface of a successful XSS, Session Storage is not considered secure browser storage as there are still XSS types (e.g., stored XSS), which can result in the sensitive data being retrieved by an attacker deploying a successful XSS. When the XSS happens in the same tab as the one that has the secret stored in Session Storage, an attacker can easily retrieve the value of a secret named secret with a single line:

sessionStorage.getItem(secret)

To showcase the differences between Local and Session Storage, we’ll use an application.

Clone the repository and checkout to the correct branch:

git clone git@github.com:esarafianou/browser-storage.git
cd browser-storage
git checkout auth0-blog

Install the dependencies

npm install

Run the application

cd localvsSessionStorage
node server.js

Now visit http://localhost:4000 and follow the steps mentioned there.

Local and Session Storage Showcase

Cookies

Cookies’ security characteristics depend on their flags, and in the case of an XSS, we are particularly interested in the HttpOnly flag.

HttpOnly:true

When the HttpOnly flag is set, the cookie cannot be retrieved by JavaScript. This means that a secret value stored in an HttpOnly cookie cannot be retrieved by the JavaScript an attacker injects in the page via XSS. However, the HttpOnly flag also means that the frontend JavaScript application cannot access the secret. Storing secrets in HttpOnly cookies comes with the requirement that only the backend application needs them.

While HttpOnly cookies protect the secret from being revealed to the attacker, the attacker can still use it indirectly by deploying an XSS+CSRF attack. Utilizing the XSS vulnerability, an attacker can inject JavaScript to send a request to an endpoint, which requires the secret stored in the HttpOnly cookie. Since the browser will happily send the cookie along with the attacker’s request, the endpoint will receive a valid request and send it back to the attacker the data.

This scenario showcases that HttpOnly cookies protect only the confidentiality of the secret, guaranteeing that the secret cannot be used “offline” outside of the context of the XSS. As long as the attacker has access to the frontend JS via XSS, they get the benefits of the secret stored in the HttpOnly cookie.

HttpOnly:false

HttpOnly:false should never be used when a cookie stores a secret because such a cookie is easily retrievable by an XSS attack. The following code retrieves all cookies with HttpOnly:false and returns the one named “secret.”

document.cookie.split('; ').find(row => row.startsWith('secret')).split('=')[1];)

In-memory

In-memory Storage of secrets is often perceived as the most secure solution compared to the ones discussed up to this point. However, in-memory Storage is a vague statement that doesn’t indicate how the secrets are actually stored in memory. In-memory Storage itself has many options. To understand all its nuances, we will utilize an application vulnerable to XSS.

Stop the current running application about Local and Session Storage and run a new one:

cd ../inMemory
node server.js

Now visit http://localhost:3000

In-memory

This is an application vulnerable to DOM-based XSS. To verify it, click the “Submit XSS” button. An alert pop-up will appear.

This application stores secret into variables in memory in 4 different ways. Let’s start with the first example.

Example #1

The first input box allows you to submit a secret. When the “Submit secret” button is clicked, the storeInMemory() function runs. storeInMemory() takes the value of the input box and stores it in the secret_1 variable. This variable is defined at the top level of the frontend JavaScript code. As such, an attacker leveraging the XSS can access it.

  1. Write a secret value in the input box, e.g., “supersecret” and click the submit button.
  2. Paste the following in the XSS Payload textbox: <img src='x' onerror='alert(secret_1);'/> and click the submit button.
  3. An alert will pop-up revealing the value submitted.

Example #2

The second input box also allows you to submit a secret. When the “Submit secret” button is clicked, the storeInMemory2() function runs. Let’s have a look at the function:

const data = {}
...
function storeInMemory2() {
  const secret_2= document.getElementById('secret2').value;
  data['secret'] = secret_2
}

storeInMemory2() takes the value of the input box and stores it in the secret_2 variable. This variable is defined within the context of storeInMemory2(). As such, an attacker doesn’t have access to secret_2. However, secret_2 is then assigned to data.secret. Because the data object is defined at the top level of the frontend JavaScript code, an attacker leveraging the XSS can access any of its properties.

  1. Write a secret value in the input box, e.g., “supersecret2” and click the submit button.
  2. Paste the following in the XSS Payload textbox: <img src='x' onerror='alert(secret_2);'/> and click the submit button.
  3. No alert pops up. Open the console in the browser’s Developer Tools. An error is logged “secret2 is not defined”, verifying that the variable `secret2defined withinstoreInMemory2()` is not accessible by an attacker.
  4. Now paste the following in the XSS Payload textbox: <img src='x' onerror='alert(data.secret);'/> and click the submit button.
  5. An alert will pop-up revealing the value submitted.

The first two examples showcase that if the secret value is assigned at any point in a variable defined at the top level of the frontend JavaScript code, an attacker will be able to retrieve its value.

Example #3

One option to secure in-memory storage is to utilize closures in order to emulate private methods. This is what is used in the 3rd example. Specifically, when the “Fetch secret” button is clicked, the backendSecret.value() function runs. This example is a bit more complex:

let backendSecret = (function() {
  async function fetchBackendSecret() {
    let fetchedSecret;
    const request = new Request("http://localhost:3000/secret");
    let response = await window.fetch(request)
    fetchedSecret = await response.json()
  }

  return {
    value: function() {
      return fetchBackendSecret();
    }
  };
})();

The closure emulating a private method is the fetchBackendSecret() function. Within this function, a request is sent to http://localhost:3000/secret using the native JS fetch function, and the JSON data of the response is stored in the locally defined fetchedSecret variable. The fetchBackendSecret() is emulating a private method because it is defined within another function. As such, fetchBackendSecret() is not accessible outside of the external function. The external function only defines fetchBackendSecret() and returns a value() function. The value() function is the one that runs the fetchBackendSecret() function. Finally, backendSecret stores the result of calling the external function.

With that in mind:

  1. Click the Fetch Secret button. This will call backendSecret.value() which in turn will call fetchBackendSecret()
  2. Paste the following in the XSS Payload textbox: <img src='x' onerror='alert(fetchedSecret);'/> and click the submit button.
  3. No alert pops up. Open the console in the browser’s Developer Tools. An error is logged “fetchedSecret is not defined,” verifying that the variable fetchedSecret defined within fetchBackendSecret() is not accessible by an attacker.
  4. Similarly paste the following in the XSS Payload textbox: <img src='x' onerror='alert(fetchBackendSecret);'/> and click the submit button.
  5. No alert pops up too. Open the console in the browser’s Developer Tools. An error is logged “fetchBackendSecret is not defined,” verifying that fetchBackendSecret is a private method.

Note that any usage of the secret value should happen within the body of the fetchBackendSecret() function. If the secret is returned by the private closure, it becomes available to the attacker.

Although this implementation looks secure from a first look, there is an important detail that circumvents the security protection of the private closure. The private closure makes use of an externally defined function: the native fetch(). As such, an attacker has access to the fetch function and can override its functionality.

Let’s look at the following code snippet:

let fetch = window.fetch;
window.fetch = async function() { 
  let fetchPromise = fetch.apply(this, arguments);
  let resp = await fetchPromise;
  let json = await resp.json();
  alert(JSON.stringify(json));
  return fetchPromise
};

The native window.fetch is first assigned to a local copy. Then a new function is assigned to window.fetch. The function behaves exactly like the native window.fetch function with the exception that it first alerts the response data before returning the result.

  1. Paste the following in the XSS Payload textbox: <img src='x' onerror="let fetch = window.fetch; window.fetch = async function() { let fetchPromise = fetch.apply(this, arguments); let resp = await fetchPromise; let json = await resp.json(); alert(JSON.stringify(json)); return fetchPromise};"/> and click the submit button.
  2. Click the Fetch Secret button to fetch the secret from the backend. Now the overridden fetch function runs.
  3. An alert pops up showing the fetched JSON, revealing the secret value secretKey.

Example #4

The last example also uses closures in order to emulate private methods but aims to address the security issue of the previous example. Similar to Example #3, Example #4 has a “Fetch secret” button, which runs the localBackendSecret.value() function when clicked.

The only difference with the previous example is that now a local copy of window.fetch is kept and used to perform the request.

let backendSecret = (function() {
  let localFetch = window.fetch;
  async function localFetchBackendSecret() {
    let fetchedSecret;
    const request = new Request("http://localhost:3000/secret");
    let response = await localFetch(request)
    fetchedSecret = await response.json()
  }

  return {
    value: function() {
      return localFetchBackendSecret();
    }
  };
})();
  1. Assuming you have already pasted the XSS payload of the previous example, simply click the Fetch Secret button to fetch the secret from the backend.
  2. No alert pops up.

Although the attacker is able to overwrite window.fetch(), the local copy localFetch is created on page load and before the XSS occurs. Since localFetch has the initial functionality of window.fetch, the attacker payload doesn’t work. It is important to note that the secret should not be returned by the private closure as this means that it becomes available to the attacker. Any usage of the secret value should happen within the body of the localFetchBackendSecret() function.

Keeping a local copy of the externally defined window.fetch was quite simple in the example, but this approach has scalability issues. In real-world examples, the private closure can have many externally defined functions. For each one of them, the code would need to keep a local copy. Additionally, in cases where non-native functions from third-party libraries are used, the local copy approach does not work as it is not possible to keep a local copy of all the functions used internally in the third party library and its dependencies.

Web Workers Help Maintain Secure Browser Storage

So far, we’ve discussed each browser storage option and clarified the security guarantees that they offer. We’ve seen that even potentially secure solutions like cookies with HttpOnly:true and private closures with local copies of externally defined functions have their shortcomings:

  • HttpOnly cookies cannot be used by the legitimate frontend application, and the attacker can use them indirectly via an XSS+CSRF attack
  • In-memory Storage within private closures with local copies of externally defined functions have scalability issues and cannot be utilized if third party libraries are in use

At this point, it’s important to take a step back and clearly define what we’re trying to achieve. Assuming that an XSS vulnerability exists, the frontend JS application should be considered compromised. Therefore our goal is to write code that handles sensitive data in an untrusted environment. To achieve this, we should be looking for ways to isolate this code from the untrusted environment. At a high level, the design should look like this:

Web Workers Help Maintain Secure Browser Storage

The safe world is where the code handling the secret will live. A communication channel between these two worlds is needed to exchange necessary information. Note that the secret should never be transmitted to the unsafe world.

The next question we need to answer is how we can create this safe world. Since we’re in a browser environment, our options are the different browser sandboxes: 1. Origin sandbox 2. Javascript Closures 3. Process sandbox

We’ve already discussed origin sandbox: Local Storage, Session Storage, cookies. Our verdict was that this is not a secure option, with the exception of HttpOnly cookies. We’ve also discussed Javascript Closures. Let’s now focus on the process sandbox of browsers and, more specifically, on Web Workers.

Web Workers: The Basics

Web Workers can run JavaScript code in a background thread separate from the main execution thread of the JS frontend application. They communicate with the frontend application via a channel called MessageChannel. Specifically, the application can send a message to the Web Worker to perform some action via the MessageChannel. The Web Worker will perform the action and send back to the application the needed information. The design now looks like this:

Web Workers

An important requirement for storing a secret within the memory of a Web Worker is that any code that requires the secret must exist within the Web Worker. The rest of the browser application must not need the secret. Otherwise, an attacker can override the MessageChannel object and retrieve the secret when it is sent back to the main JS application.

Similar to the HttpOnly cookies, storing secrets within the memory of a Web Worker protects the confidentiality of the secret: An attacker is unable to obtain the value of the secret. However, they can use it indirectly by sending messages to the Web Worker to perform an operation that requires the secret and returning back the result to them. An example of such an operation is a request to the backend.

The advantage of a Web Worker implementation compared to an HttpOnly cookie is that with an HttpOnly cookie, no JS code can access the value of the secret. With Web Workers, the secret is available in the isolated JavaScript code of the Web Worker. If JS frontend code needs access to the secret, the Web Worker implementation is the only one satisfying the requirement while preserving the secret confidentiality.

Below is a sample code of a Web Worker implementation. The main application logic (app.js) creates a new Worker passing the worker code file as an argument. To retrieve the user data, it posts a message to the Worker passing the user ID as an argument.

The Web Worker (worker.js) receives the message, and the onmessage function is executed. If a token doesn’t exist in the secretCache for the user, the Worker will send a request to the backend to retrieve the token and store it in the secretCache object. Then it sends a request to retrieve the user info sending the token and the userID as body arguments. Only the user data received from the backend are sent to the main JavaScript application. The token remains stored in the Worker.

app.js

const worker = new Worker("worker.js");
let userData;
let userId = '5f8d7bd2418fef006838d504'

// send data, e.g. userID to a worker
userData = worker.postMessage(userId)

worker.js

// object in which the secret will be stored
const secretCache = {}
const tokenUrl = 'https://backend.example.com/token/'

onmessage = async (userId) => {
  if (!secretCache.userId)
    // fetch a token for the user
    try {
      response = await fetch(tokenUrl + userId);
    } catch (error) {
      return;
    }   
    json = await response.json();

    // store the secret token in the secretCache with the userId as a key
    secretCache.userId = json.token

  // retrieve data based on token
  const userUrl = 'https://backend.example.com/user/'
  const data = { 
    userId: userId,
    token: secretCache.userId
  }
  try {
    let response = await fetch(userUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },  
      body: JSON.stringify(data)
    }); 
  } catch (error) {
    return;
  }
  json = await response.json();

  // return the retrieved data to the main application
  postmessage(data)
}

Secure Browser Storage in Auth0 SDKs

Auth0’s library for Single Page Applications: https://github.com/auth0/auth0-spa-js handles Refresh Tokens with in-memory Storage within Web Workers. Refresh Tokens are only used by the application to be sent to Auth0 to issue new Refresh Tokens and Access Tokens. No other functionality on a client application needs Refresh Tokens. As such, they are a great example of a secret that can be kept securely within Web Workers. Any code responsible for sending or receiving Refresh Tokens is isolated within the Web Worker so that the Refresh Token never exits the Web Worker. This guarantees that their value cannot be retrieved by an attacker in the case of an XSS.

Using Browser Storage Best Practices Helps Keep Data Secure

The key takeaway of what we’ve discussed so far is that If a secret cannot be kept in isolation, any browser storage is susceptible to XSS. The different storage options that provide some level of effective isolation are HttpOnly cookies, simple cases of in-memory Storage within private closures with local copies of externally defined functions, and Web Workers.

None of the options are bulletproof from a security perspective; still, they protect the confidentiality of the secret. Finding the right solution depends on your application requirements but always consider moving away from a browser storage design to a Backend-For-Frontend (BFF) one, where the secret is stored in the backend.

To learn more about how Auth0 secures identity data in the browser, reach out to our IAM specialists and start the conversation.

About Auth0

Auth0 by Okta takes a modern approach to customer identity and enables organizations to provide secure access to any application, for any user. Auth0 is a highly customizable platform that is as simple as development teams want, and as flexible as they need. Safeguarding billions of login transactions each month, Auth0 delivers convenience, privacy, and security so customers can focus on innovation. For more information, visit https://auth0.com.

  • Twitter icon
  • LinkedIn icon
  • Faceboook icon