The most secure way to store JWTs

Published - 3 min read

There are two common methods for storing JSON Web Tokens (JWTs). The first method is to store them in an HTTP-only cookie, while the second method uses a JavaScript storage mechanism like Local Storage.

The HTTP-only cookie method helps prevent Cross Site Scripting (XSS) attacks, but it can be vulnerable to Cross Site Request Forgery (CSRF) if not handled correctly. In contrast, storing JWTs in Local Storage exposes the token to XSS attacks.

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

The idea is to store the JWT in a variable within the service worker. Since a service worker is a special type of web worker, external scripts cannot access this variable. This setup protects the JWT from XSS attacks. Additionally, because service workers cannot be activated by scripts from different origins, CSRF attacks are not possible. These two features make the service worker an ideal location for the token.

To implement this authentication flow, you will need an authentication endpoint that returns a JWT when the user submits their credentials.

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

For example, let’s consider an endpoint /login that authenticates a user’s credentials and returns a JWT in the response body. Here is what your service worker will look like:

js
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));
});

Essentially, the service worker “captures” the JWT token from /login and injects it whenever access to a specific resource is required.

The downside of this approach is that when the user quits or closes the browser, the JWT token is lost, requiring the user to go through the authentication process again. This can be beneficial for websites that demand high security, such as banks and stock trading platforms.

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