DEV Community

Cover image for JWT Authentication in React: Guide to Access Tokens & Refresh Tokens
Aleksandra Dudkina
Aleksandra Dudkina

Posted on • Originally published at aleksandradudkina.hashnode.dev

JWT Authentication in React: Guide to Access Tokens & Refresh Tokens

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:

  1. 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"
Enter fullscreen mode Exit fullscreen mode

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:

  1. User logs in → receives access token in body + refresh token in cookie

  2. For API requests, application includes access token

  3. When access token expires, application detects this (or receives 401) and calls refresh endpoint

  4. Browser automatically includes refresh token cookie with refresh request

  5. Server sends new access token (the user does not need to log in again)

  6. Application retries original request with new token

  7. 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"
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
// };
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

What we have:

  • we store the full access token string and we can get it with tokenStorage.getToken() anywhere in the app

  • we 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);

Enter fullscreen mode Exit fullscreen mode

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",
});
Enter fullscreen mode Exit fullscreen mode
  • 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"
Enter fullscreen mode Exit fullscreen mode

If you go to DevTools → Application → Cookies, you will see the cookie with the refresh token.

Screenshot of http-cookie refresh token in dev tools

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);
    }
Enter fullscreen mode Exit fullscreen mode

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;
    }
Enter fullscreen mode Exit fullscreen mode

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"
});

Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
Enter fullscreen mode Exit fullscreen mode

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);
}

Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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]);

Enter fullscreen mode Exit fullscreen mode

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,
      },
    ]);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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');
    } 
};
Enter fullscreen mode Exit fullscreen mode

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'); 
    } 
};
Enter fullscreen mode Exit fullscreen mode

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();
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

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)