What Are Access Tokens and Refresh Tokens? (And Why You Need Both)
Before diving into implementation, let’s first understand the HTTP endpoints your frontend communicates with and what each of them does.
A step by step flow in short:
- On login request we send the login and password to the server. The backend checks whether they match an existing user. If no, we will get an error response (typically 401 Unauthorized). If yes, we will get a 200 response with a response body similar to this:
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTYyMzkwMjIsInVzZXJfaWQiOjEsInJvbGUiOjJ9.RDsEg737AOckmF_rXiZehecfKZQZV_Zr_csnRZnJZVM"
We will see later what to do with this response. What we need to remember for now is that we get an access token on authentication request (login request in simple words).
Besides access token, the server will also send us a refresh token. You may wonder where it is, as we don't see anything besides access token in the response. Refresh token is sent in HTTP-only cookies and cannot be directly accessed in your app. Refresh token is stored in browser.
What we need to remember for now:
Access token is what we work with in our app. It usually contains some important information about the user (like id, role, etc.), which is validated by the backend (the backend is always the source of truth). This token is sent with every request. The server uses it to verify that the user is authenticated and has the required permissions. Also, access token has its expiration time, for example, 15 minutes. Usually, access token is short-lived. So to stay authorized, we will need to request a new access token when it expires. And this is why refresh token is needed.
Refresh token is used to get a new access token. This token is automatically sent to the server (this happens on the browser side).
In short: when the access token expires, we send a refresh request, and the browser automatically includes the refresh token cookie, and we receive a new access token.
Think of authentication as:
access token = temporary pass
refresh token = ability to get a new pass without logging in again
Flow recap:
User logs in → receives access token in body + refresh token in cookie
For API requests, application includes access token
When access token expires, application detects this (or receives 401) and calls refresh endpoint
Browser automatically includes refresh token cookie with refresh request
Server sends new access token (the user does not need to log in again)
Application retries original request with new token
When user logs out, refresh token is invalidated on server and removed from browser
How to Decode a JWT Token
As you remember, after we send a login request and get a success response, we get something similar to this in response body:
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTYyMzkwMjIsInVzZXJfaWQiOjEsInJvbGUiOjJ9.RDsEg737AOckmF_rXiZehecfKZQZV_Zr_csnRZnJZVM"
If you look carefully, you see that the long string we received from server is divided into 3 parts by dots (.)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. - this is header
ehJ1c2VyX2lkIjoxMjL0NSwicm9sZSI6ImNsaWVudCIsImlzcyI6Ik15QXBwIiwiaWF0IjoxNzYwMDAwMDAwLCJleHAiOjE3NjAwMDAzMDB9. - this is payload
dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk - this is signature
We can read the payload (the middle part) to extract some useful information. This part is encoded (meaning anyone can decode and read it) and typically contains data such as user ID, role, or other details.
Besides the user data, it also contains token-related data, the most important part for us is token expiration time.
There are libraries for decoding token payload.
What I personally used when working on a project:
online token decoders just for quick checks and tests
a library to decode in my app. Personally, i used "jwt-decode" library
What i did is i wrote a decoder function with the library:
import { jwtDecode } from "jwt-decode";
import type { JwtPayload } from "jwt-decode";
// if you work in TypeScript
// this might be useful for correct storing and checking
// the values you extract from payload for later usage
export interface accessTokenPayload extends JwtPayload {
user_id: number;
role: number;
// other user data
}
export function jwtDecodePayload(token: string) {
return jwtDecode<accessTokenPayload>(token);
}
And decoded the payload after I received it from the server:
// login request
const response = await fetch(`${api}/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
// this line is crucial to receive refresh token,
// we will take a deeper look at it later
credentials: "include",
body: JSON.stringify({
login: login,
pass: password,
}),
});
const data = await response.json();
//the function we created above
const tokenPayload = jwtDecodePayload(data.access_token);
console.log(tokenPayload)
// {
// exp: 1716239022, - expiration time, timestamp in seconds
// user_id: 1,
// role: 2
// };
Now we can use tokenPayload in our app.
Note: we can read this data on the frontend, but we should not fully trust it, the server always validates it.
Before using it, we need to store it somewhere.
Token Storage Considerations
If you search on the internet, you will see that when it comes to where to store access token, many mention storing it in localStorage or sessionStorage. When implementing authorisation logic on the project I've been working on, our team decided that none of these will be used. Storing tokens in localStorage is convenient but has security risks. That's why many teams prefer keeping access tokens in memory.
Our decision was to store access token in memory (RAM). What it means in simple words: just store it as a JavaScript variable. The variable will be deleted from the memory every time we do a page refresh (this is where we will use refresh token to request a new access token). This is why we often call the refresh endpoint when the app loads - to restore the session after a page refresh, so the user doesn’t have to log in again.
Architecture note: Store only token-related data in
tokenStorage. Store user data (login, role, etc.) separately in global state (Redux, Context, etc.) - keep token logic isolated.
But we have many values: expiration time, user ID, user role, so it’s not just a single value as we need to store multiple pieces of data.
My solution was to create an object that will be used around the app. This is a simplified setup that demonstrates the idea:
class TokenStorage {
private accessToken;
private tokenExp; //timestamp in seconds
setToken(token, tokenPayload) {
this.accessToken = token;
this.tokenExp = tokenPayload.exp;
}
getToken() {
return this.accessToken;
}
isTokenActive() {
if (!this.accessToken) {
return false;
}
if (!this.tokenExp) {
return false;
}
return Date.now() < this.tokenExp * 1000;
}
// we will need this to handle logging out
clearToken() {
this.accessToken = null;
this.tokenExp = null;
}
}
// the variable will be created when we load the app in the browser
export const tokenStorage = new TokenStorage();
What we have:
we store the full access token string and we can get it with
tokenStorage.getToken()anywhere in the appwe store token expiration time and can quickly check if access token hasn't expired by just calling
tokenStorage.isTokenActive()
So when we receive access token from the server and decode it, we store it in the tokenStorage variable.
// response from login request
const response = await fetch(`${api}/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
//this line below enables storing refresh token! we will explore it later
credentials: "include",
body: JSON.stringify({
login: login,
pass: password,
}),
});
const data = await response.json();
// decoded payload
const tokenPayload = jwtDecodePayload(data.access_token);
// we set the whole access token string and token payload object
tokenStorage.setToken(data.access_token, tokenPayload);
Now we can access token anywhere in the app, for example, check if access token is still valid by just calling tokenStorage.isTokenActive()
Access Token Usage
Let’s briefly recap why we need the access token:
- It is passed in all HTTP requests
//we get current access token stored in app
const token = tokenStorage.getToken()
const myRequest = await fetch(`${api}/request`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}` //we pass token to server
},
credentials: "include",
});
- It is passed as a query parameter in WebSocket requests (explained in WebSocket part below).
What happens if token is expired (or if the user is not authorized at all): server will respond with 401 Unauthorized. This is the moment we need a new access token and this is when we need refresh token!
How the Refresh Token Flow Works (and How to Implement It)
First, let's investigate where refresh token is stored and how we use it to get a new access token. Remember, the body response from server didn't include any refresh token.
Let's take a step back.
This is the login request we did and the response body:
// login request
const response = await fetch(`${api}/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include", //take a look here
body: JSON.stringify({
login: login,
pass: password,
}),
});
// body response:
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTYyMzkwMjIsInVzZXJfaWQiOjEsInJvbGUiOjJ9.RDsEg737AOckmF_rXiZehecfKZQZV_Zr_csnRZnJZVM"
If you go to DevTools → Application → Cookies, you will see the cookie with the refresh token.
This is the refresh token!
Refresh token is sent in HTTP-only cookie and is stored in browser. This works because of credentials: "include" line, the browser will not store cookies sent by the server in the response if credentials: "include" is not set (It took me a while to find this bug when I was implementing authorisation flow myself).
Note: Since refresh tokens are stored in cookies and sent automatically by the browser, the backend usually needs CSRF protection to prevent malicious websites from making requests on behalf of the user without their consent.
credentials: "include" handles both receiving HTTP-only cookies (refresh token in our case) and sending them back to the server automatically. If there are HTTP-only cookies stored in browser, credentials: "include" line in your request will automatically send them to server.
Getting a new valid access token is not difficult. We just need to send a request to a dedicated endpoint and include HTTP-only cookies (refresh token from browser).
Here is a simplified logic:
// refresh token request
const response = await fetch(`${api}/refresh`, {
method: "GET",
headers: { "Content-Type": "application/json" },
credentials: "include", //send refresh token stored in browser
});
if (response.ok) {
const data = await response.json();
// receiving and storing a new access token
const payload = jwtDecodePayload(data.access_token);
tokenStorage.setToken(data.access_token, payload);
}
Logging out
// logout request
const response = await fetch(`${api}/logout`, {
method: "GET",
headers: { "Content-Type": "application/json" },
credentials: "include", //send refresh token stored in browser
});
if (response.ok) {
// we need to clear the local token variable!
tokenStorage.clearToken();
return;
}
At this moment the server invalidates the refresh token (deletes it from database) on its side. If you try to use the same refresh token after it was cleared on the server (after the logout), you will get 401 Unauthorized error.
Remember i mentioned, that storing access token in a variable means that on each page refresh access token will be deleted from the memory. Will we have to call refresh token on each page refresh?
Note that refresh token is not infinite-long either. It has its expiration time too. How do we check if refresh token is still valid? What will happen if it is already expired but we keep sending it to server?
There are even more questions to be raised:
What if user is not authorized at all? What if unauthorized user tries to navigate to some protected page with just inserting URL in browser search line? How to prevent an authorized user to navigate to login page? If user changed password on another device, how should be log them out automatically?
Protected routes and handling user authorisation states
I will share my personal experience of handling authorisation on routing level, but remember that this part can be implemented in various different other ways, depending on the project setup.
We use React Routing in the project i've been working on, but if you use Tanstack Routing for example, the core idea remains the same.
Before handling routing and protected routes, let's investigate, how our app understands if refresh token is still valid and if the user is authorized or not.
A short answer is: the server handles it all.
The server is the source of truth. The frontend only reacts to responses (like 401 Unauthorized).
Let's take a look at few scenarios:
- We make a HTTP request and send access token in Headers.
//we get current access token stored in app
const token = tokenStorage.getToken()
//some request
const myRequest = await fetch(`${api}/request`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}` //we pass token to server
},
credentials: "include"
});
Server checks if the access token we provided is expired. If access token is expired, server will send us the 401 Unauthorized error.
- We make a refresh token request:
// refresh token request
const response = await fetch(`${api}/refresh`, {
method: "GET",
headers: { "Content-Type": "application/json" },
credentials: "include", //send refresh token stored in browser
});
if (response.ok) {
const data = await response.json();
// receiving and storing a new access token
const payload = jwtDecodePayload(data.access_token);
tokenStorage.setToken(data.access_token, payload);
}
The server checks whether the refresh token is valid.
- If refresh token is expired, server will send us the 401 Unauthorized error.
- If refresh token is invalid, server will send us the 401 Unauthorized error (for example, the user logged out with this refresh token before, and this refresh token isn't valid anymore on server side).
This is where routing comes into place: if user is not authorized, we should navigate them to some /auth path.
First, let's clear local token storage if the user is not authorized anymore.
This is what we were already doing in logout request:
// logout request
const response = await fetch(`${api}/logout`, {
method: "GET",
headers: { "Content-Type": "application/json" },
credentials: "include", //send refresh token stored in browser
});
if (response.ok) {
// we need to clear the local token variable! !
tokenStorage.clearToken();
return;
}
Let's add additional checks to refresh token request. Remember, refresh token request can respond with 401 Unauthorized error and we need to log the user out!
// refresh token request
const response = await fetch(`${api}/refresh`, {
method: "GET",
headers: { "Content-Type": "application/json" },
credentials: "include", //send refresh token stored in browser
});
// Server checks if the refresh token we provided is valid.
// - If refresh token is expired, server will send us the 401 Unauthorized error.
// - If refresh token is invalid, server will send us the 401 Unauthorized error
if (response.status === 401) {
// clear local access token as the user needs to be logged out
tokenStorage.clearToken();
}
if (response.ok) {
const data = await response.json();
// receiving and storing a valid access token
const payload = jwtDecodePayload(data.access_token);
tokenStorage.setToken(data.access_token, payload);
}
Refresh request result:
200 OK → new access token → user stays logged in
401 Unauthorized → refresh token invalid → user is logged out
Auth checks outside of route level would look something like this in a simplified way:
const MainPage = () => {
// when app is initialised, the tokenStorage is empty, we need to get a new access token
refreshRequest()
// if there is no access token stored or it is already expired, log out user
useEffect(() => {
// check if the current access token is still valid
if (!tokenStorage.isTokenActive()) {
navigate("/auth") //depends on which routing lib you use
}
}, [navigate]);
The same on /auth path:
const LoginPage = () => {
// when app is initialised, the tokenStorage is empty, we need to get a new access token
refreshRequest()
// if there is already an active access token, the user is already authorized
useEffect(() => {
// check is current access token in still valid
if (tokenStorage.isTokenActive()) {
navigate("/main") //depends on which routing lib you use
}
}, [navigate]);
What happens here is:
The component is rendered
→ Refresh request is called to check if user is authorized
→ token check
→ navigation
This means that if the user is authorized, they might briefly see the LoginPage before being redirected to the MainPage. And vice versa. This happens because we first need to wait the component to fully render before useEffect runs. This means the user may briefly see the wrong page before being redirected - a noticeable flicker.
Using route loaders solves this.
Using Route Loaders for Auth Checks (No Page Flicker)
Route is matched
→ loader runs (refresh + token check)
→ navigation happens
→ only then the page renders
No unnecessary renders. No flickering.
Flow recap:
Login → access token + refresh token
Request → send access token → OK
Access token expired → refresh request (cookie auto-sent) → new access token → retry request
Refresh token expired → logout → redirect to
/auth
To implement it, we go to the place in the project where we do all the routing setup. In my case, it was in main.tsx component:
router = createBrowserRouter([
{
path: "/main", // this is the main page
element: <MainPage />,
loader: rootLoader,
},
{
path: "/auth", //this is auth page
element: <LoginPage />,
loader: authLoader,
},
]);
This is React Router (data mode), but this logic can be implemented with almost any other library. All the route-level requests and anything we want to do before page rendering happens inside the loader function.
Tanstack router docs for loaders
React Router docs with loader example - this is example for data mode, framework mode can differ a bit.
We pass to the loaders functions all the checks we need:
async function rootLoader() {
// if there is no active access token, wait for a new one
if (!tokenStorage.getToken()) {
await refreshTokenRequest();
}
// if we didn't receive any new valid access token, it means the user is NOT authorized, navigate to auth
if (!tokenStorage.isTokenActive()) {
return redirect("/auth");
}
}
return null;
}
async function authLoader() {
// if there is no active access token, wait for a new one
if (!tokenStorage.getToken()) {
await refreshTokenRequest();
}
// if we received a new valid access token, it means the user is already authorized, navigate to the main page
if (tokenStorage.isTokenActive()) {
return redirect("/");
}
return null;
}
Flow recap:
We refresh the main page → refresh token request is sent →
res 401 → no valid access token, clear the remaining access token we had in memory → navigate user to /auth → render auth page
res ok → we received and stored a new valid access token → render main page
Login → access token + refresh token
Request → access token → OK
Access expired →
→ refresh request (cookie auto sent)
→ new access
→ retry request
Refresh expired →
→ logout
→ redirect to /auth
Handling JWT Auth with WebSockets
Usually, access token is added as a query parameter to the WebSocket endpoint.
Example:
const accessToken = 'your_access_token_here';
// we attach access token directly to websocket endpoint
// to the query param "access_token"
const websocketEndpoint = `wss://example.com/socket?access_token=${accessToken}`;
const socket = new WebSocket(websocketEndpoint);
socket.onopen = function(event) {
console.log('WebSocket is open now.');
};
// Example of handling a WebSocket error response indicating unauthorized access
socket.onclose = function(event) {
if (event.code === 1008) { // 1008 is a policy violation code, often used for unauthorized access
console.error('WebSocket closed due to unauthorized access');
}
};
If you send invalid or expired access token, the connection will get closed and you will receive 1000 or 4001 error code (some servers use custom codes like 4001 to indicate an expired access token; always check your backend docs).
socket.onclose = function(event) {
if (event.code === 1008 || event.code === 4001) {
// 1008 is a policy violation code, often used for unauthorized access
// 4001 is a custom application code typically used for invalid or expired tokens
console.error('WebSocket closed due to unauthorized access');
}
};
At this moment, there are two possible reasons:
access token got expired
the user got unauthorized (for example, the user logged out in another browser window).
In both cases backend sees that the access token we send isn't valid anymore.
Usually, when socket connection is closed, you try to reconnect a few times. We will try to reconnect here too. But if we try to reconnect with the same invalid access token, we will be getting the same error response. We need to get a new access token first, before trying to reconnect.
So it would be something like:
socket.onclose = async function (event) {
if (event.code === 1008 || event.code === 4001) {
// 1008 is a policy violation code, often used for unauthorized access
// 4001 is a custom application code typically used for invalid or expired tokens
console.error("WebSocket closed due to unauthorized access");
// requesting a new access token with refresh request, store a new token in memory
await refreshTokenRequest();
// just for safety check again if we got a valid access token
if (tokenStorage.isTokenActive()) {
// reconnect
connectWebSocket();
}
}
};
Common close codes to handle:
1008 — policy violation (unauthorized access)
4001 — custom code often used for expired tokens (always check your backend docs)
If you try to reconnect with the same invalid token, you will keep getting the same error. Always request a new access token first, then reconnect.
Tip: Add a maximum reconnect attempt limit to avoid infinite retry loops.
Recap
We covered the full JWT authentication flow in React:
Access token is stored in memory and sent with every request
Refresh token lives in an HTTP-only cookie and is handled automatically by the browser
Tokens should be stored in memory, not localStorage, to avoid security risks
Use a local object to manage token state across your app
When access token expires, call the refresh endpoint to get a new one without logging the user out
Use route loaders instead of useEffect for auth checks to avoid page flicker
For WebSockets, always refresh the token before reconnecting after a 1008 or 4001 close code
The server is always the source of truth, the frontend only reacts to what the server tells it.

Top comments (0)