Hello π! In this blog, I will show you how I usually implement axios interceptors when I build an app that requires authentication. In this case, we will use React, but in can easily be ported to another framework (Most of the time I did it in Vue).
We will use the backend from this blog post.
For this, I've created a starter repository for us to focus only on the refresh token part. You can clone it with this command:
npx degit mihaiandrei97/blog-refresh-token-interceptor react-auth
Inside it, you will see two folders:
- react-auth-start: here is the code that you will be using for this project.
- react-auth-finished: here is the final code, if you missed something and you need to check it.
Project explanation
The application has 2 pages:
- A Login page, with a form where the user can register/login and after that we save the tokens in localStorage.
- A Home page where we display the user profile if he is logged in.
For the user state management, we will use zustand (because we need to access the tokens inside axios interceptors, and that can't be done with React Context because the state is not accessible outside components).
I like to keep all of my api calls inside a folder called services
. With this approach, I can see all the calls used in the app.
Step 1 - Create Axios Interceptor for request
As a first step, let's define the axios interceptors. You can read more about them here, but as a simple explanation, we will use them to execute some code before we make a request, or after we receive a response.
This is what we will implement:
Let's create a file called services/createAxiosClient.js
:
Here, we will define a function that will create our axios instance. We will use that instance everywhere in the app, instead of axios
. If we do that, for each request/response, our interceptors will be executed.
import axios from 'axios';
export function createAxiosClient({
options,
getCurrentAccessToken,
getCurrentRefreshToken,
refreshTokenUrl,
logout,
setRefreshedTokens,
}) {
const client = axios.create(options);
client.interceptors.request.use(
(config) => {
if (config.authorization !== false) {
const token = getCurrentAccessToken();
if (token) {
config.headers.Authorization = "Bearer " + token;
}
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
return client;
}
The createAxiosClient takes the following arguments:
- options: the options that are passed to the axios instance, example: baseUrl, timeout etc.
- getCurrentAccessToken: a function that provides the accessToken from the store.
- getCurrentRefreshToken: a function that provides the accessToken from the store.
- refreshTokenUrl: the url endpoint that should be called when the access token is expired.
- logout: a function that performs the logout logic when the refreshToken called failed( ex: cleanup storage / redirect to /login)
- setRefreshedTokens: a function that sets the tokens in store/localStorage.
We could move the logic directly into createAxiosClient
instead of passing those helpers functions, but with this approach, we can easily move the axiosInstance to a different state management (ex: Redux), or to a different framework (Vue / Svelte).
The request interceptor that we just wrote does a simple thing. Checks if the specific request requires authentication, and if it does, it calls the method: getCurrentAccessToken
, and adds the token to the header in order to be passed along to the server.
With this approach, we no longer have to manually specify the access token for each request that we write. We just need to use this axios instance.
Step 2 - Create the services
Let's create the file where we will put all of our logic that creates the axios instance.
Create a file in the services
directory called axiosClient.js
.
import { createAxiosClient } from "./createAxiosClient";
import { useAuthStore } from "../src/stores/authStore";
const REFRESH_TOKEN_URL = 'http://localhost:5000/api/v1/auth/refreshToken'
const BASE_URL = 'http://localhost:5000/api/v1/'
function getCurrentAccessToken() {
// this is how you access the zustand store outside of React.
return useAuthStore.getState().accessToken
}
function getCurrentRefreshToken() {
// this is how you access the zustand store outside of React.
return useAuthStore.getState().refreshToken
}
function setRefreshedTokens(tokens){
console.log('set tokens...')
}
async function logout(){
console.log('logout...')
}
export const client = createAxiosClient({
options: {
baseURL: BASE_URL,
timeout: 300000,
headers: {
'Content-Type': 'application/json',
}
},
getCurrentAccessToken,
getCurrentRefreshToken,
refreshTokenUrl: REFRESH_TOKEN_URL,
logout,
setRefreshedTokens
})
In this file, we call the createAxiosClient
function and we export the client in order to use it in our services. We have also defined the URL's (BASE_URL and REFRESH_TOKEN_URL), and we used zustand in order to get the tokens from the global state.
Now, let's create the services.js
file, where we would store all of our api calls.
import { client } from "./axiosClient";
export function register({ email, password }) {
return client.post(
"auth/register",
{ email, password },
{ authorization: false }
);
}
export function login({ email, password }) {
return client.post(
"auth/login",
{ email, password },
{ authorization: false }
);
}
export function getProfile() {
return client.get("/users/profile");
}
Here, we imported the client instance, and we use it to make requests like we would normally do with the axios
keyword.
If you notice, for the login/endpoint, we specified authorization: false
, because those endpoints are public. If we omit it, then by default it will fire the getCurrentAccessToken
function.
Now, let's change the axios calls with the one from services.
Let's go to the Login
page and in the action, change the following code:
const url =
type === "register"
? "http://localhost:5000/api/v1/auth/register"
: "http://localhost:5000/api/v1/auth/login";
const { data } = await axios.post(url, {
email,
password,
});
with:
const response = type === "register" ? await register({email, password}) : await login({email, password});
const { accessToken, refreshToken } = response.data;
Now, if you try to register/login, it should work like before. The interceptor doesn't really have a point for this endpoints, but you could say it is better structured now.
Next, let's go to the Home
page.
There, instead of:
useEffect(() => {
if (isLoggedIn)
axios
.get("http://localhost:5000/api/v1/users/profile", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
.then(({ data }) => {
setProfile(data);
})
.catch((error) => {
if (error.response.data.message === "TokenExpiredError") {
logout();
}
});
}, [isLoggedIn, accessToken]);
We can add:
useEffect(() => {
if (isLoggedIn) {
getProfile().then(({data}) => {
setProfile(data);
}).catch(error => {
console.error(error);
})
}
}, [isLoggedIn]);
It looks much cleaner now. You can see that we do not manually set the Authentication header anymore since the axios interceptor does that for us.
Also, probably you noticed that we are not checking anymore for the "TokenExpiredError". We will do that in the response interceptor soon.
Step 3 - Create Axios Interceptor for response
Here, things get a bit more complicated, but I will try to explain it the as good as I can :D. If you have questions, please add them in the comments.
The final code for createAxiosClient.js
is:
import axios from "axios";
let failedQueue = [];
let isRefreshing = false;
const processQueue = (error) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve();
}
});
failedQueue = [];
};
export function createAxiosClient({
options,
getCurrentAccessToken,
getCurrentRefreshToken,
refreshTokenUrl,
logout,
setRefreshedTokens,
}) {
const client = axios.create(options);
client.interceptors.request.use(
(config) => {
if (config.authorization !== false) {
const token = getCurrentAccessToken();
if (token) {
config.headers.Authorization = "Bearer " + token;
}
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
client.interceptors.response.use(
(response) => {
// Any status code that lie within the range of 2xx cause this function to trigger
// Do something with response data
return response;
},
(error) => {
const originalRequest = error.config;
// In "axios": "^1.1.3" there is an issue with headers, and this is the workaround.
originalRequest.headers = JSON.parse(
JSON.stringify(originalRequest.headers || {})
);
const refreshToken = getCurrentRefreshToken();
// If error, process all the requests in the queue and logout the user.
const handleError = (error) => {
processQueue(error);
logout();
return Promise.reject(error);
};
// Refresh token conditions
if (
refreshToken &&
error.response?.status === 401 &&
error.response.data.message === "TokenExpiredError" &&
originalRequest?.url !== refreshTokenUrl &&
originalRequest?._retry !== true
) {
if (isRefreshing) {
return new Promise(function (resolve, reject) {
failedQueue.push({ resolve, reject });
})
.then(() => {
return client(originalRequest);
})
.catch((err) => {
return Promise.reject(err);
});
}
isRefreshing = true;
originalRequest._retry = true;
return client
.post(refreshTokenUrl, {
refreshToken: refreshToken,
})
.then((res) => {
const tokens = {
accessToken: res.data?.accessToken,
refreshToken: res.data?.refreshToken,
};
setRefreshedTokens(tokens);
processQueue(null);
return client(originalRequest);
}, handleError)
.finally(() => {
isRefreshing = false;
});
}
// Refresh token missing or expired => logout user...
if (
error.response?.status === 401 &&
error.response?.data?.message === "TokenExpiredError"
) {
return handleError(error);
}
// Any status codes that falls outside the range of 2xx cause this function to trigger
// Do something with response error
return Promise.reject(error);
}
);
return client;
}
The workflow can be seen in this diagram:
Now, let's take it step by step.
The queue implementation:
We create an array failedQueue
where we put the requests that failed at the same time when we tried to refresh the token. When is this usefull? When we have multiple calls in paralel. If we make 4 requests, we probably don't want each of them to trigger a refreshToken. In that case, only the first one triggers it, and the other ones are put in the queue, and retried after the refresh is finished.
Refresh token logic
First, we get the token via getCurrentRefreshToken
function that was passed to createAxiosClient
.
Second, we need to check the following:
- Do we have a refresh token?
- Did we receive a response with status 401 and the message TokenExpiredError?
- Is the url different from the Refresh token url? (because we do not want to trigger it if the refresh token responds with an expired message)
- Is this the first time we try this request? (originalRequest?._retry !== true)
If all this conditions are true, then we can go further. If the isRefreshing
flag is already true, it means we triggered the refresh with an early call, so we just need to add the current call to the queue. If not, then this is the first call, so we change the flag to true, and we proceed with the refreshToken call to the back-end. If the call is successful, we call 'setRefreshTokens' that was passed to the client, we process the queue(start all the requests from the queue with the new tokens), and we retry the original request that triggered the refresh.
If the refresh token was missing, or it was expired, we just process the queue as an error and we logout the user.
Now, the last thing we need to do write the logic for setRefreshTokens and logout.
Go to services/axiosClient.js
and change them like this:
function setRefreshTokens(tokens){
console.log('set refresh tokens...')
const login = useAuthStore.getState().login
login(tokens)
}
async function logout(){
console.log('logout...')
const logout = useAuthStore.getState().logout
logout()
}
Conclusion
And that's it. Now, by using axios interceptors, your app should automatically add the access token to the header and also handle the refresh token silently, in order to keep the user authenticated πππ.
If you have any questions, feel free to reach up in the comments section.
Top comments (9)
Hi great article π.
Please can you share the link to a github repository (if any)?
The thing, I try to implement this using reduxjs and as you use zustand I wanted to know how you define the store. For instance when you do use AuthStore.getState().login I don't know if login is a reducer action or not so I'm a little bit confuse.
Hello! For sure. I thought it is understandable by default that degit goes to github for this:
npx degit mihaiandrei97/blog-refresh-token-interceptor react-auth
Here is the repo: github.com/mihaiandrei97/blog-refr...
I switched from React to Vue recently and I really don't want to go back so is there a simple way to use most of this code here in Vue without rewriting major portions of it? I'm fine with minor rewrites, that's expected but I don't want to basically rewrite the whole thing myself. Thanks for this and the backend article! They helped a ton!
The axios part is the same. The useEffects could be changed into created or watchers in vue
Okay, thanks!
Hello, I just want to ask question, I tried converting this to typescript and I don't have any luck.
This is error that I get:
And this happens when I call client.post()
Any help man.
I ran into the same issue. You need to overwrite the AxiosRequestConfig. Just place this anywhere in your code. I placed it on top of the createAxiosClient function.
Thanks! Good to know
Hello. Can you create a repo and share the url So I can check?