The most secure way to store JWTs

There are two common ways to store JSON Web Tokens (JWTs). The first way is to store it in an HTTP-only cookie, and the second via a JavaScript storage mechanism such as Local Storage.

The HTTP-only cookie method prevents Cross Site Scripting (XSS) attacks, but it is vulnerable to Cross Site Request Forgery (CSRF) unless properly dealt with. On the other hand, storing JWTs in Local Storage makes the token vulnerable to XSS.

What if there is a third way that is always secure?

The idea is to store the JWT in the service worker in a variable. As a service worker is a special type of web worker, external scripts cannot access the variable. This protects the JWT from XSS attacks. Furthermore, since service workers cannot be activated by scripts from different origins, CSRF is not possible. These two properties make the service worker a good place to put the token in.

To make this authentication flow work, you will first need an authentication endpoint that sends back a JWT when the user submits their credentials.

The moment the browser receives the response, it will go through the service worker first. Here, the service worker will retrieve and store the JWT as a variable. The main browser script should not have access to this JWT, and only know if authentication was successful or not.

Let's suppose we have an endpoint /login that would authenticate a user's account credentials and send back a JWT in its body, this is what your service worker will look like:

const authUrls = ['/login'];
const protectedUrls = ['/protected'];

// Prevent the service worker from
// waiting until next page load to take over.
self.addEventListener('activate', (event) => {
  event.waitUntil(clients.claim());
});

// Here is the main crux of the idea:
// store the auth token in the service worker
let authToken = null;

function interceptRequest(request) {
  const url = new URL(request.url);
  const isSameOrigin = self.location.origin === url.origin;
  const isProtectedUrl = isSameOrigin && protectedUrls.includes(url.pathname);
  const isAuthUrl = isSameOrigin && authUrls.includes(url.pathname);

  // Attach auth token to header only if required
  if (authToken && isProtectedUrl) {
    // Clone headers as request headers are readonly
    const headers = new Headers(Array.from(request.headers.entries()));
    // Attach auth token to header.
    headers.append('Authorization', authToken);
    // Make a new request because clones are readonly
    try {
      request = new Request(request.url, {
        method: request.method,
        headers: headers,
        mode: 'same-origin',
        credentials: request.credentials,
        cache: request.cache,
        redirect: request.redirect,
        referrer: request.referrer,
        body: request.body,
        context: request.context,
      });
    } catch (e) {
      // This will fail for CORS requests. We just continue with the
      // fetch caching logic below and do not pass the ID token.
    }
    return fetch(request);
  } else if (isAuthUrl) {
    // Stash the auth token
    return fetch(request).then((response) =>
      response.json().then((data) => {
        // Capture the auth token here
        authToken = data.token;

        const newBody = JSON.stringify({
          success: data.success,
          message: data.message,
        });
        // Make a new reponse because clones are readonly
        return new Response(newBody, {
          status: response.status,
          statusText: response.statusText,
          headers: new Headers(Array.from(response.headers.entries())),
        });
      }),
    );
  }

  return fetch(request);
}

// Intercept all fetches
self.addEventListener('fetch', (event) => {
  event.respondWith(() => interceptRequest(event.request));
});

In essence, the service worker "steals" the JWT token from /login and injects the JWT token whenever it is needed to access a particular resource.

The tradeoff of this method is that whenever the user quits or closes the browser, the JWT token is lost, causing the user to have to go through the authentication flow again. This may be an advantage in the case of websites that require high levels of security, such as banks and stock trading sites.

The service worker code above is part of a working demo that demonstrates the login flow. With this, you should be able to set up your secure authentication flow for your personal or professional work.