Introduction
In this post, I will show you how you can implement a silent refresh on React using Typescript, setInterval, axios, and zustand.
A year prior to writing this post, I attended an internal seminar hosted by the Young Software Engineers’ Society (an academic organization I’m affiliated with) wherein one of our alumnus thought us backend development from the theoretical level down to its implementation. On the last part, they briefly discussed authentication using json web tokens and how to secure your app using access and refresh tokens. To help us better understand it, they sent a link to Hasura’s guide to securing JWT . When I first read the article, I was so confused as to how we can implement silent refresh on React.
Almost a year later, I revisited the article because I was working on a new project, a desktop app that is, and we had to implement silent refresh on it. After several trial and errors, I finally got a running prototype that implements silent refresh on the background. And in this article I will share to you how I did it.
Prerequisite
Once again, I won’t dive in too much on how silent refresh works. You can read up Hasura’s guide if you need a refresher.
To proceed, you must be at least familiar with the ff. topics / technologies
- React & React Hooks
- yarn (if you’re using npm, just install it)
- Typescript
- axios (or any http-fetching library)
- async / await
- Zustand
- JSON Web Tokens
- Git
- Have some familiarity with Node, Express, or backend development in general
Setting-up Backend Server
To speed things up, I’ve prepared a backend server that you can clone for this mini tutorial. You may clone this repo by visiting this link or by running the ff. commands in your shell / command line
git clone https://github.com/dertrockx/example-auth-server.git
SECURITY WARNING
In Hasura’s guide, it was advised that the backend attaches the refreshToken to a secure HTTP cookie, so that the client does not have access to the refreshCookie using Javascript. However, for simplicity’s sake I didn’t do that, and instead I will let the client store the refreshToken as they please, as such, this is an unsecure way of storing refreshTokens. Please be aware if you were to do this.
After cloning the repository, run the ff. commands to install all dependencies and start the server
yarn # this is equivalent to 'npm install'
yarn dev # this is equivalent to 'npm run dev'
After running the commands above, your terminal / command line should look like this:
The server provides two different endpoints that we will use for this mini tutorial. These are:
-
POST /auth/login
an endpoint that returns an access token, a refresh token, and a token_expiry - an integer value that tells you in milliseconds how long until the access token expires -
GET /auth/refresh
an endpoint that returns a new set of token (access and refresh) and token_expiry - an integer value that tells you in milliseconds how long until the access token expires. This checks the header for a refresh token w/ the header nameAuthorization
and w/ a value ofBearer ${token-goes-here}
Now that the backend is ready, let’s proceed with the frontend
Creating the frontend application
First, we need create a blank react app that uses Typescript. For simplicity’s sake, we’ll use create-react-app with Typescript as the template. To do so, run the ff. commands
yarn create-react app --template typescript silent-refresh-app
# the command above is equivalent to running npx create-react-app --template typescript silent-refresh-app
After initializing the project, we need to cd
to the created directory. Just run cd ./silent-refresh-app
and install other dependencies that we will use
yarn add zustand axios # npm install zustand axios
- Zustand is a state management library that primarily encourages devs to use hooks and requires less boilerplate code than Redux
- Axios is an http client for the browser - it’s an alternative to the browser’s native Fetch API
Create auth.service.ts
Once we have installed the dependencies, we can now send requests to the backend server. In order to do that, we need to create a new instance of axios w/ custom config. Simply create a new file called axios.ts
under src/lib
directory, w/ the ff. content:
import axios from "axios";
// Creates a new instance of axios
// Just export this instance and use it like a normal axios object
// but this time, the root endpoint is already set
// So, when you do axios.get("/personnel") under the hood it actually calls axios.get("http://<your-path-to-backend-uri>")
const instance = axios.create({
baseURL: "<your-path-to-backend-uri>" // can be http://localhost:8000
});
export default instance;
After doing so, we need to import this on a separate file that will call our backend api. We need to create a file called auth.service.ts
under src/services/
and add the ff. content
import http from "../lib/http";
import axios, { AxiosError } from "axios";
// This interface is used to give structure to the response object. This was directly taken from the backend
export interface IHttpException {
success: boolean;
statusCode: number;
error: string;
code: string;
message: string;
details?: any;
}
// A custom error that you can throw to signifiy that the frontend should log out
export class ActionLogout extends Error {}
// service function to login
/**
* An function that attempts to log in a user.
* Accepts a username and a password, and returns the tokens and the token expiration or throws an error
*/
export async function login({
username,
password,
}: {
username: string;
password: string;
}): Promise<
| {
auth: string;
refresh: string;
tokenExpiry: number;
}
| undefined
> {
try {
const credentials = {
username: "admin",
password: "password123",
};
// this is equal to http.post("http://<your-backend-uri>/auth/login", credentials);
const res = await http.post("/auth/login", credentials);
const {
token: { auth, refresh },
token_expiry,
} = res.data;
return { auth, refresh, tokenExpiry: token_expiry };
} catch (err) {
const error = err as Error | AxiosError;
if (axios.isAxiosError(error)) {
const data = error.response?.data as IHttpException;
console.log(data.message);
console.log(data.code);
return;
}
console.error(error);
}
}
/*
* An asynchronous function that refreshes the authenticated user's tokens.
* Returns a new set of tokens and its expiration time.
*/
export async function refreshTokens(token: string): Promise<
| {
auth: string;
refresh: string;
tokenExpiry: number;
}
| undefined
> {
try {
// This is equivalent to http.get("http://<path-to-uri>/auth/refresh", { ... })
const res = await http.get("/auth/refresh", {
headers: {
Authorization: `Bearer ${token}`,
},
});
const {
token: { auth, refresh },
token_expiry,
} = res.data;
return { auth, refresh, tokenExpiry: token_expiry };
} catch (err) {
const error = err as Error | AxiosError;
if (axios.isAxiosError(error)) {
const data = error.response?.data as IHttpException;
console.log(data.message);
console.log(data.code);
if (data.code === "token/expired") {
throw new ActionLogout();
}
}
console.error(error);
return;
}
}
After creating the services, we can then proceed with setting up our store
Setting up Zustand Store
Zustand uses hooks, instead of the traditional duck-typing pattern of redux (yes, Redux now has slices, but for simplicity sake I used zustand because it is super lightweight and requires less boilerplate code to set-up compared to Redux).
To create a new store, simply create a file named auth.store.ts
under src/store/
and add the ff. content (don’t worry, I’ll explain what they do)
import create from "zustand";
import { devtools } from "zustand/middleware";
interface IAuthState {
tokens: {
auth: string;
refresh: string;
};
count: number;
tokenExpiry: number;
authenticate: (
tokens: {
auth: string;
refresh: string;
},
tokenExpiry: number
) => void;
logout: () => void;
increment: () => void;
}
export const useAuth = create<IAuthState>()(
devtools((set, get) => ({
count: 0,
tokens: {
auth: "",
// We will store the refresh token in localStorage. Again, this is an unsecure option, feel free to look for alternatives.
refresh: localStorage.getItem("refreshToken") || "",
},
tokenExpiry: 0,
increment: () => set({ count: get().count + 1 }),
logout: () => {
localStorage.setItem("refreshToken", "");
set(() => ({
tokens: {
auth: "",
refresh: "",
},
tokenExpiry: 0,
}));
},
authenticate: (tokens, tokenExpiry) => {
localStorage.setItem("refreshToken", tokens.refresh);
set(() => ({
tokens,
tokenExpiry,
}));
},
}))
);
To export the created store, create an index.ts
file under src/store/
that will export all of the content from src/store/auth.ts
. Add the ff. content
// src/store/index.ts
export * from "./auth.ts"
Why do we need this? So that when we want to use the auth store, all we have to do is import it from the folder, not the file itself
// sample code when you want to import `useAuth`
// Assuming you're in a file under the 'src' directory
import { useAuth } from "./store"
Edit App.tsx
Now that we have created our services and store, we then edit App.tx
and use them inside it.
import React, { useCallback, useRef } from "react";
import "./App.css";
// start of 1
import { useAuth } from "./store";
import { login, refreshTokens, ActionLogout } from "./services/auth.service";
import { useEffectOnce } from "./hooks";
// end of 1
function App() {
// start of 2
const {
tokens: { refresh, auth },
tokenExpiry,
logout,
authenticate,
} = useAuth((state) => state);
const intervalRef = useRef<NodeJS.Timer>();
// end of 2
// start of 3
useEffectOnce(() => {
if (refresh) {
// try to renew tokens
refreshTokens(refresh)
.then((result) => {
if (!result) return;
const { auth, refresh, tokenExpiry } = result;
authenticate({ auth, refresh }, tokenExpiry);
intervalRef.current = setInterval(() => {
console.log("called in useEffect()");
sendRefreshToken();
}, tokenExpiry);
})
.catch((err) => {
if (err instanceof ActionLogout) {
handleLogout();
}
});
}
});
// end of 3
// start of 4
const handleLogout = useCallback(() => {
logout();
clearInterval(intervalRef.current);
// eslint-disable-next-line
}, [intervalRef]);
const handleLogin = useCallback(async () => {
const res = await login({ username: "admin", password: "password123" });
if (!res) {
return;
}
const { refresh: newRefresh, tokenExpiry, auth } = res;
authenticate({ auth, refresh: newRefresh }, tokenExpiry);
intervalRef.current = setInterval(() => {
sendRefreshToken();
}, tokenExpiry);
// eslint-disable-next-line
}, [refresh]);
const sendRefreshToken = async () => {
const refresh = localStorage.getItem("refreshToken")!;
try {
const result = await refreshTokens(refresh);
if (!result) {
return;
}
const { auth, refresh: newRefresh, tokenExpiry } = result;
authenticate({ auth, refresh: newRefresh }, tokenExpiry);
} catch (error) {
if (error instanceof ActionLogout) {
handleLogout();
}
}
};
// end of 4
// start of part 5
return (
<div className="App">
<p>
{auth ? (
<button onClick={() => handleLogout()}>Log out</button>
) : (
<button onClick={() => handleLogin()}>Login</button>
)}
</p>
<p>
Token expiry:{" "}
{tokenExpiry !== 0 && new Date(Date.now() + tokenExpiry).toUTCString()}
</p>
<p>Auth token: {auth}</p>
<p>Refresh token: {refresh}</p>
</div>
);
// end of part 5
}
export default App;
I know what you’re thinking, what the hell did I just copy-pasta-d into my code? Don’t worry I’ll explain them, part-by-part
Part 1: Imports
First, we need to import three things - the service provider, the store, and a custom hook called useEffectOnce
. What is this custom hook?
This custom hook let’s you run a useEffect only once. Since React 18, useEffect runs twice on development mode (insert link here). To prevent that, I’ll link a medium article that basically only runs useEffect once - on mount.
Since this is a custom hook, you need to create this. Create a file calledsrc/hooks.ts
w/ the ff. content
import { useRef, useState, useEffect } from "react";
export const useEffectOnce = (effect: () => void | (() => void)) => {
const destroyFunc = useRef<void | (() => void)>();
const effectCalled = useRef(false);
const renderAfterCalled = useRef(false);
const [, setVal] = useState<number>(0);
if (effectCalled.current) {
renderAfterCalled.current = true;
}
useEffect(() => {
// only execute the effect first time around
if (!effectCalled.current) {
destroyFunc.current = effect();
effectCalled.current = true;
}
// this forces one render after the effect is run
setVal((val) => val + 1);
return () => {
// if the comp didn't render since the useEffect was called,
// we know it's the dummy React cycle
if (!renderAfterCalled.current) {
return;
}
if (destroyFunc.current) {
destroyFunc.current();
}
};
// eslint-disable-next-line
}, []);
};
To save time, I’ll just attach a link to the original medium article that further explains this.
Part 2: Getting state and Ref
In part of the App.tx
file, you can see that we extract the state values and actions that are inside auth.ts
Since we need to renew the tokens every X seconds (where X is any integer > 0 in milliseconds) and send a request to the backend, we are going to user setInterval
and store its intervalId without triggering a re-render. To do so, we have to use useRef
and pass a type of NodeJS.Timer
to let Typescript do its magic by giving suggestions when we write code.
const {
tokens: { refresh, auth },
tokenExpiry,
logout,
authenticate,
} = useAuth((state) => state);
// we pass NodeJS.Timer to useRef as its value's type
const intervalRef = useRef<NodeJS.Timer>();
Part 3: Using the custom hook useEffectOnce
Starting React 18, a component is mounted, unmounted, then mounted again. This makes useEffect hooks that have no dependencies run twice - that is why we had to use a custom useEffect hook that will only run once (I forgot where I originally found the custom hook - I’ll leave something in the comment section or I’ll update this once I find it).
The function passed inside the useEffectOnce
is just like any normal function passed to a useEffect
hook. On initial page load, we want to fetch a new set of tokens (access and refresh) and re-fetch another set of tokens every X seconds (tokenExpiry). Here, we call the function refreshTokens()
from the auth.service.ts
where we pass in a refresh token. It returns a promise that resolves a new auth (or access) token, refresh token, and a tokenExpiry. We will then update the store, and start the silent refresh process.
useEffectOnce(() => {
if (refresh) {
// try to renew tokens
refreshTokens(refresh)
.then((result) => {
if (!result) return;
const { auth, refresh, tokenExpiry } = result;
// Update the store
authenticate({ auth, refresh }, tokenExpiry);
// start the silent refresh
intervalRef.current = setInterval(() => {
sendRefreshToken();
}, tokenExpiry);
})
.catch((err) => {
// if the service fails and throws an ActionLogout, then the token has expired and in the frontend we should logout the user
if (err instanceof ActionLogout) {
handleLogout();
}
});
}
});
Part 4: The methods that handle login, logout, and sendRefreshToken
Now that we have set up the background refresh on initial load, I then explain the functions that are called when the user clicks on the button to login / logout and sending of refresh token.
But first, I know what you’re thinking - But Ian, why are you using useCallback, and what the hell is it? - useCallback
is a hook that React provides out-of-the-box that accepts two paremeters - a function, and a list of dependencies. The function passed is cached and is only rebuilt when the dependencies change.
Why does this exist? Because when a component re-renders, functions inside of it are rebuilt too and it hits the performance of your app (you can further google it). For small-scale apps, this is not much of an issue but for big apps, this is very crucial. So, developers need to find a way to cache functions and only rebuild them when necessary - hence useCallback
was created.
const handleLogout = useCallback(() => {
logout();
clearInterval(intervalRef.current);
// eslint-disable-next-line
}, [intervalRef]);
const handleLogin = useCallback(async () => {
const res = await login({ username: "admin", password: "password123" });
if (!res) {
return;
}
const { refresh: newRefresh, tokenExpiry, auth } = res;
authenticate({ auth, refresh: newRefresh }, tokenExpiry);
intervalRef.current = setInterval(() => {
sendRefreshToken();
}, tokenExpiry);
// eslint-disable-next-line
}, [refresh]);
const sendRefreshToken = async () => {
const refresh = localStorage.getItem("refreshToken")!;
try {
const result = await refreshTokens(refresh);
if (!result) {
return;
}
const { auth, refresh: newRefresh, tokenExpiry } = result;
authenticate({ auth, refresh: newRefresh }, tokenExpiry);
} catch (error) {
if (error instanceof ActionLogout) {
handleLogout();
}
}
};
The first function, handleLogout()
is a memoized function that runs logout()
from the useAuth()
hook (which clears the store) and cleans up the function that runs in the background (the silent-refresh part) identified by intervalRef.
The second function, handleLogin()
, is a memoized function that runs when the user presses the Login
button. Internally, it calls login()
that then tries to send user credentials to the backend server. If it succeeds, then it returns a new set of tokens (auth and refresh) and a tokenExpiry. We then use this tokenExpiry to send a request to the backend server to refresh (see what I did there?) the tokens and refresh it again - creatinga silent refresh feature.
The last function, sendRefreshToken()
is a function that is called by the handleLogin()
function that refreshes the token. As you can see here, we access the refresh token by directly accessing it from the localStorage instead of via the store. Why? TBH I’m not really certain why - somehow a Zustand store state does not persiste when it is referenced inside setInterval
.
Part 5: Rendering UI
After defining all of the functions and logic, we then render JSX content that uses the login / logout functions depending on the state of the store
return (
<div className="App">
<p>
{auth ? (
<button onClick={() => handleLogout()}>Log out</button>
) : (
<button onClick={() => handleLogin()}>Login</button>
)}
</p>
<p>
Token expiry:{" "}
{tokenExpiry !== 0 && new Date(Date.now() + tokenExpiry).toUTCString()}
</p>
<p>Auth token: {auth}</p>
<p>Refresh token: {refresh}</p>
</div>
);
Once you are done with everything, save it, and run the dev server by running the ff. command to your shell / command line
Once you are done with everything, save it, and run the dev server by running the ff. command to your shell / command line
yarn start # this is equivalent to npm start
If it runs, it should automatically open your browser at http://localhost:3000. If it doesn’t, you can just open it for yourself. You should see something like this.
By default, I set the expiration time of the auth token to 5 seconds and for the refresh token to 10 seconds. As you can see, the tokens are refreshed every 5 seconds. Also, if you try to refresh the page, the tokens are still refreshed every 5 seconds since it runs the silent-refresh on initial page load.
To test if the refresh token actually expires, you can close the tab, wait for more than 10 seconds, and then revisit the same site. It should not run the silent-refresh on the background and instead automatically logout since the refresh token has expired. Also, you should see something like this (note: you have to open your dev tools to see the error)
To re-run the silent refresh, just click on login.
Conclusion
Implementing silent refresh is tricky - you have to use setInterval to run a function periodically and you must ensure that this function is cleared if not used.
Silent refresh is a nice security feature, but this article only scrapes the tip of the iceberg - for further readings, I highly recommended reading hasura’s official guide.
Here’s a copy of the frontend repository → https://github.com/dertrockx/react-silent-refresh/
Here’s for the backend → https://github.com/dertrockx/example-auth-server
Top comments (4)
I suggest anyone reading this, to take a look at react-query where we can do all this in 2 lines of code.
Hello @ecyrbe ! I have not used react-query before, so I may not be familiar with it. Can you give an example of what you are talking about? Thanks!
Here is a simple example in code sandbox to illustrate simple mecanism with react query.
Thanks for sharing this @ecyrbe , I wish I knew this before I wrote this post lol