Go back

Monkey patching: refresh token mechanism in React

I typically write my own AuthProvider on the client for most of the projects I work on, and it covers all the needs — at least, as far as I know — of the application concerning its auth state.

Recently though, I started running into a situation where the access_token from the backend API I'm using expires, and for the life of me, everything in the app crashes. On my end too, I'm not handling this error — gracefully.

And as expected, Next.js throws that very annoying big black screen with "A client-side exception error occurred, check the browser console for more info" error in my face every time. Skill issue! I know.

Creating an error boundary that handles this gracefully, does not take the problem away. it only just provides aesthetics to cover my shame, the access_toke will expire.

Intercepting Requests? or Responses? which way?

I made up my mind that I was just gonna ignore this particular issue. But, when O. and I had a conversation around auth, this issue — around refresh tokens — came up. It gave me a nudge to stop being lazy.

The recommended way to solve this, from the resources I've seen on the internet, was to use interception, an Axios feature. However, I use Next.js, often without Axios, so this wouldn’t work, and probably because the sample code snippets I came across were too long or required a couple of things — Installing a new dependency, especially.

I read about middlewares in Next.js and tried setting up something similar and I ran into the same issue again — too much code, and still couldn't get it to work, "skill issue!" again, you might term it.

Monkey patching

I needed to look for a way to intercept the request in the app. This was only possible with Axios interceptors before I found out about Monkey patching.

Since it is a pattern that allows me to modify or update the behavior of my fetch requests as I've read. It was the best option to follow. Patching the native fetch API can be simple as the snippet below

const { fetch: originalFetch } = window

But, always remember to check if the window object is undefined if you're using Next.js. if you don't it'll throw a Reference error exception that "window is not defined"

Modifying my AuthProvider

I already had an endpoint to get a new access_token in case of an expiry, so I wrote the function that does specifically that for me. With that sorted out, I went on to include the refreshToken function inside the provider like so

context/auth-provider.tsx
const [state, dispatch] = useReducer(authReducer, initialState)
 
const handleTokenRefresh = React.useCallback(async () => {
  const newAccessToken = await refreshToken()
  if (newAccessToken) {
    const userAccessInfo = {
      ...state.accessInfo,
      access: newAccessToken.access,
    }
 
    // @ts-ignore
    dispatch({ type: 'SET_ACCESS', payload: userAccessInfo })
    setCookie('tees', userAccessInfo, {
      path: '/',
      sameSite: 'strict',
      maxAge: 14 * 24 * 60 * 60,
    })
  }
}, [state.accessInfo])

When I get the new token, I update the auth state and my current cookie data with the new token — for this, I use next-cookies

Remember how mentioned checking if window is not undefined, yeah? So here it is in action in the snippet below.

context/auth-providr.tsx
if (typeof window !== 'undefined') {
  const { fetch: originalFetch } = window
 
  window.fetch = async (...args: Parameters<typeof originalFetch>) => {
    const response = await originalFetch(...args)
 
    if (response.status === 401) {
      await handleTokenRefresh()
      const headers = args?.[1]?.headers as HeadersInit
 
      if (!headers) return
 
      if (typeof headers === 'object') {
        headers as Record<string, string>[
          'Authorization'
        ] = `Bearer ${state?.accessInfo?.access}`
      }
 
      return originalFetch(...args)
    }
 
    return response
  }
}
 
React.useEffect(() => {
  handleTokenRefresh()
}, [])

In the snippet above You'll notice how the handleTokenRefresh function is coupled to the response status. Which is quite ideal, because when the access_token expires, all request fail with a Unauthoried: 401 error

To avoid that, after obtaining the new access_token I proceeded to include it by reading the value from my auth state. Remember: dispatch({ type: 'SET_ACCESS', payload: userAccessInfo })?

A lot of the type inference above was so I could fix some of the errors Typescript kept throwing. You won't need any of this if your project doesn't use Typescript

Where to go from here? | wrapping up.

To full-proof the whole experience, you can also check for the expiry time of the JWT instead of relying soley on the request failure. This package jwt-decode does that very well.

const decodedToken = jwtDecode(userAccessInfo.access as string)
 
if (decodedToken?.exp * 1000 < Date.now()) {
  handleTokenRefresh()
}

It is also good to know that the native fetch can be modified to intercept network requests. I thought this was only possible with Axios.

I can think of so many possibilities with this pattern now. I'll let you know what I find in the future. But for now? I'll go back to some of my old projects (requiring auth) and ensure this is included.

If you read up to this point, thank you!