Implement http-only jwt tokens with retry
Hi,
I’m trying to refresh the access token when I receive a 401 error and retry all pending requests. I could use onResponse and onRequest.
Something like this:
This works fine when you only have one request at a time, but when you have multiple requests on the page:
It just doesn't work (multiple
const $api = (endpoint) => {
useFetch(endpoint, {
onResponse: async (context) => {
const { statusCode } = context;
if (statusCode === 401) {
try {
await refreshToken();
retryWith(options);
} catch (e) {
navigateTo("/auth/login");
}
}
},
});
};
const $api = (endpoint) => {
useFetch(endpoint, {
onResponse: async (context) => {
const { statusCode } = context;
if (statusCode === 401) {
try {
await refreshToken();
retryWith(options);
} catch (e) {
navigateTo("/auth/login");
}
}
},
});
};
<script setup>
$api("/api/1");
$api("/api/2");
</script>
<script setup>
$api("/api/1");
$api("/api/2");
</script>
refreshToken
calls). Any ideas on how to approach this?2 Replies
I was thinking about something like:
not sure if this will work
" these are not actual codes "
This is the code we used for one of our internal services ( React, next.js )
But implementing a global state like this:
in an SSR app doesn't make sense, as it causes the state to be shared across all users, if I'm not mistaken?
const useApi = () => {
const isRequestingNewToken = false;
const queue = [];
const $api = $fetch.create({
baseURL: config.public.api,
credentials: "include",
retry: 1,
retryStatusCodes: [401],
onResponse: async ({ response, options, request }) => {
if (response.status === 401) {
try {
if (isRequestingNewToken)
return;
await refreshToken();
queue.forEach((config) => {
retryWith(config)
})
}
catch (error) {
options.retry = false;
await authStore.logout();
console.error("Token refresh failed:", error);
}
}
},
});
return $api;
};
const useApi = () => {
const isRequestingNewToken = false;
const queue = [];
const $api = $fetch.create({
baseURL: config.public.api,
credentials: "include",
retry: 1,
retryStatusCodes: [401],
onResponse: async ({ response, options, request }) => {
if (response.status === 401) {
try {
if (isRequestingNewToken)
return;
await refreshToken();
queue.forEach((config) => {
retryWith(config)
})
}
catch (error) {
options.retry = false;
await authStore.logout();
console.error("Token refresh failed:", error);
}
}
},
});
return $api;
};
const API_URL = process.env.NEXT_PUBLIC_API_URL;
let isRefreshing = false;
const refreshAndRetryQueue: RetryQueueItem[] = [];
const createApiClient = () => {
const axiosConfig: any = {
baseURL: API_URL,
withCredentials: true,
headers: {
"Content-Type": "application/json",
},
};
return axios.create(axiosConfig);
};
const client = createApiClient();
client.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest: AxiosRequestConfig = error.config;
if (error.response.status === 401 error.response.status === 403) {
if (!isRefreshing) {
isRefreshing = true;
try {
// Refresh the access token
await refreshAccessToken();
refreshAndRetryQueue.forEach(({ config, resolve, reject }) => {
client
.request(config)
.then((response) => resolve(response))
.catch((err) => reject(err));
});
// Clear the queue
refreshAndRetryQueue.length = 0;
return client(originalRequest);
} catch (refreshError) {
throw refreshError;
} finally {
isRefreshing = false;
}
}
// Add the original request to the queue
return new Promise<void>((resolve, reject) => {
refreshAndRetryQueue.push({ config: originalRequest, resolve, reject });
});
}
return Promise.reject(error);
}
);
export { client };
const API_URL = process.env.NEXT_PUBLIC_API_URL;
let isRefreshing = false;
const refreshAndRetryQueue: RetryQueueItem[] = [];
const createApiClient = () => {
const axiosConfig: any = {
baseURL: API_URL,
withCredentials: true,
headers: {
"Content-Type": "application/json",
},
};
return axios.create(axiosConfig);
};
const client = createApiClient();
client.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest: AxiosRequestConfig = error.config;
if (error.response.status === 401 error.response.status === 403) {
if (!isRefreshing) {
isRefreshing = true;
try {
// Refresh the access token
await refreshAccessToken();
refreshAndRetryQueue.forEach(({ config, resolve, reject }) => {
client
.request(config)
.then((response) => resolve(response))
.catch((err) => reject(err));
});
// Clear the queue
refreshAndRetryQueue.length = 0;
return client(originalRequest);
} catch (refreshError) {
throw refreshError;
} finally {
isRefreshing = false;
}
}
// Add the original request to the queue
return new Promise<void>((resolve, reject) => {
refreshAndRetryQueue.push({ config: originalRequest, resolve, reject });
});
}
return Promise.reject(error);
}
);
export { client };
let isRefreshing = false;
const refreshAndRetryQueue: RetryQueueItem[] = [];
let isRefreshing = false;
const refreshAndRetryQueue: RetryQueueItem[] = [];
useState or Pinia is your friend.