How to use OAuth PKCE in client-side JS

22 May 2023


Photo by Micah Williams on Unsplash


You need to use some API that requires authentication. It doesn’t have an SDK. 2 years ago, I was in this position, I started building Timetabl. The API I was using uses OAuth, a protocol for authorising third-party applications, to access a user’s data. I started digging through articles about OAuth. Looking at the available libraries for client side OAuth, I didn’t seem to have many options. Most JS client libraries were either not actively maintained, didn’t support PKCE (Proof Key for Code Exchange), didn’t run in the browser, or were too complicated for me to understand.

If you don’t understand much about OAuth, or just want a refresher, check out this article for an explainer.

What not to do

Stubborn, I dug into the RFCs for several weeks, and tried to wrap my head around the complexities of OAuth. In around a month I got a basic prototype implementation working. As my application grew, I started to realise that ~90% of my bugs came from that OAuth implementation. As the complexity increased it became almost impossible to maintain. Eventually, I decided to start looking for OAuth libraries again.

A library that makes OAuth easy

After some research, I found badgateway/oauth2-client. It is an excellent library that is actively maintained. I’m surprised it dosen’t have a bigger community, so give it a ⭐ and contribute if you can. This library has unit tests, and is fairly well-documented.

Registering your application

Go to the dashboard of your OAuth provider, and create a new application. You will see a page like this:

OAuth setup dashboard

You can ignore the “Client Secret” field, as we will be using PKCE to authenticate. Note down the “Client ID” and “Redirect URI” fields as we will need them for later.

Setting up the library

Install the library with npm:

npm i @badgateway/oauth2-client

Next, setup the client class:

import { OAuth2Client, generateCodeVerifier } from "@badgateway/oauth2-client";

// Replace the following values with your own
const client = new OAuth2Client({
  server: "https://my-auth-server/",
  clientId: "...",
  tokenEndpoint: "/api/token",
  authorizationEndpoint: "/api/authorize",
});

The values for server, tokenEndpoint, and authorizationEndpoint will be in your OAuth provider’s documentation. The clientId is the “Client ID” you noted down earlier.

Logging in

Add a login button to your page, and add the following code to the click handler:

const codeVerifier = await generateCodeVerifier();
// You should really dynamically generate a random string, but this is fine for a demo
const state = "some-string";

document.location = await client.authorizationCode.getAuthorizeUri({
  // The redirect uri you noted down earlier
  redirectUri: "https://my-app.example/",
  state,
  codeVerifier,
  // see your OAuth provider's documentation for available scopes
  scope: ["scope1", "scope2"],
});

// Save the code verifier and state to the localStorage
localStorage.setItem("codeVerifier", codeVerifier);
localStorage.setItem("state", state);

This code redirects the user to the OAuth provider’s login page. After the user logs in, they will be redirected back to your application. This code will extract the code and state from the URL, and exchange the code for an access token.

// Get query string
const query = Object.fromEntries(
  new URLSearchParams(window.location.search).entries()
);

// Error check
if (query.error) {
  // Handle error
  // ...

  // Logout
  localStorage.setItem("loggedIn", false);
}

// If the server returned an authorization code, attempt to exchange it for an access token
if (query.code) {
  const codeVerifier = localStorage.getItem("codeVerifier");
  const state = localStorage.getItem("state");

  const oauth2Token = await client.authorizationCode.getTokenFromCodeRedirect(
    document.location as any,
    {
      /**
       * The redirect URI is not actually used for any redirects, but MUST be the
       * same as what you passed earlier to "authorizationCode"
       */
      redirectUri: config.redirect_uri,

      state,
      codeVerifier,
    }
  );

  // Save the token object to localStorage
  localStorage.setItem("token", JSON.stringify(oauth2Token));
}

// Clear query string
window.history.replaceState({}, null, location.pathname);

Logging out

To log out, simply remove the token from localStorage and set loggedIn to false:

localStorage.removeItem("token");
localStorage.setItem("loggedIn", false);

Making requests

To do this we need to setup the FetchWrapper:

const fetchWrapper = new OAuth2Fetch({
  client,

  getNewToken: () => {
    // This method will only be called when refreshing the tokens fails
    // You should log the user in again here

    // `return null` if you want to fail this step
  },

  storeToken: (token) => {
    // This method will be called when the token is refreshed
    // You should save the token to localStorage here
    localStorage.setItem("token", JSON.stringify(token));
  },
  },

  getStoredToken: () => {
    // This method will be called to get the token from storage
    return JSON.parse(localStorage.getItem("token"));
  },
});

To send a request, simply call fetchWrapper.fetch which behaves similarly to plain old fetch:

const response = await fetchWrapper.fetch("https://my-api.example/");

// Obviously do some error handling too...

const data = response.json();

This FetchWrapper will automatically refresh the token when it expires, and retry the request. It will also handle sending the access tokens.

Is it secure to store the token in localStorage?

The main issue with localStorage is that it can be accessed by JS. In modern applications, you will often have thousands of dependencies, and if any of them are compromised then you could end up with an XSS attack. You can partially mitigate this risk with a strong Content Security Policy. The JS supply chain is quite resilient to compromised packages, with them being taken down quickly, and the community being notified. It is also very hard to pull off such a targeted attack, through a compromised package.

However, let’s assume that your app is already compromised through XSS. Even if the token was not stored in localStorage, there would be some other mechanism to make API requests (possibly through a proxy server which stores tokens as HTTP-only cookies), and the attacker could still use that by sending requests from the context of your app.

In our case, the attacker will be able to access the token from localStorage, and possibly use it even when the user does not have the app open. However, there is a measure against this called refresh token rotation.

Refresh Token Rotation

Refresh token rotation ensures that only one party is using the tokens at one time. This prevents attackers from stealing the token and using it outside the application. It goes hand in hand with short-lived access tokens.

Short-lived access tokens

Access tokens should be short-lived, and expire after a short period of time. This could be anywhere from 5 minutes to 1 hour. This means that even if the attacker steals the token, they can only use it for a short period of time, before they have to use the refresh token to refresh it.

The security advantage of refresh token rotation

When the client requests a new access token, it also receives a new refresh token. If a malcious client uses the same refresh token to retrieve a new access token, the server will detect that the refresh token has been used twice, and will revoke all refresh and access tokens. This means that the attacker cannot use the refresh token outside the application, as it will be revoked as soon as the user opens the app.

Read more about refresh token rotation here.