DEV Community

Cover image for Authentication with JWT - where to store? - Access & Refresh Tokens
ZeeshanAli-0704
ZeeshanAli-0704

Posted on • Edited on

Authentication with JWT - where to store? - Access & Refresh Tokens

What is User Authentication & Authorization?

Authentication

Verifying a user's or an entity's identity is the process called Authentication. It entails validating the user's credentials, such as a username and password, to ensure that the user is who they claim to be.

Authorization

The process of authorizing or refusing access to particular resources or functions within an application is known as Authorization. Once a user has been verified as authentic, the program checks their level of authorization to decide which areas of the application they can access.

Authentication in React using JWTs (JSON Web Tokens) with access and refresh tokens is a common approach to manage user sessions securely and efficiently. Here’s a detailed explanation of how this works:

To implement JWT token management in a React application, we can leverage the Axios library for making HTTP requests. Axios is a popular JavaScript library that simplifies the process of sending asynchronous HTTP requests to the server.

1. JWT Basics

A JSON Web Token (JWT) is a compact, URL-safe token composed of three parts: a header, a payload, and a signature.

Header: Typically consists of two parts:

  • the type of the token (JWT) and
  • the signing algorithm (e.g., HMAC SHA256).

Payload: Contains the claims, which are statements about an entity (typically, the user) and additional data. Common claims include iss (issuer), exp (expiration time), sub (subject), and aud (audience).

Signature: Ensures that the token hasn’t been altered. It is created by combining the encoded header, encoded payload, a secret, and the algorithm specified in the header.

2. Access and Refresh Tokens

Access Token: A short-lived token (e.g., 15 minutes) used to access protected resources. It contains enough information to identify a user and their permissions.

Refresh Token: A long-lived token (e.g., 7 days, 30 days) used to obtain a new access token once the old one expires. It is stored securely and is only sent to the server during the refresh process.

3. Workflow in React

Initial Authentication

Login Request:
The user logs in by providing their credentials (username and password). This login credentials need to passed via HTTPS.

Detail article on Is it ok to send plain-text password over HTTPS?

The React application sends a POST request to the authentication server with these credentials.

Token Issuance:

If the credentials are valid, the server generates an access token and a refresh token.
The server sends both tokens back to the client.

Storing Tokens:

The React application receives the tokens and stores them securely, typically in memory or in a secure storage mechanism like HTTP-only cookies or secure local storage.

Token Refreshing:

When the access token expires, the client uses the refresh token to request a new access token from the authentication server.

The server verifies the refresh token, and if valid, issues a new access token.

The client updates its stored access token and continues making requests.

4. Token Expiry and Refresh

Token Expiry:

When the access token expires, the client will typically receive a 401 Unauthorized response.

Refresh Token Request:

The React application detects the expired access token and sends a request to the server to get a new access token using the refresh token.

New Access Token:

If the refresh token is valid, the server issues a new access token and sometimes a new refresh token.
The client stores the new access token and continues to make authenticated requests.

This request to the new token is made at the interceptor layer & will re-try the actual API call which client requested.

What is Refresh Token used for?

A refresh token is a special kind of token used in authentication systems to obtain a new access token without requiring the user to authenticate.

How Refresh Tokens Work

  1. Initial Authentication:
    When a user logs in, the server validates the credentials and issues both an access token and a refresh token.

  2. Access Token Usage:
    The client (e.g., a React app) uses the access token to make authenticated requests to protected resources or APIs. This token is included in the request headers.

  3. Access Token Expiry:
    Access tokens are designed to expire after a short period (e.g., 15 minutes to 1 hour). When the access token expires, the client will receive a 401 Unauthorized response from the server.

  4. Using the Refresh Token:
    When the access token expires, the client sends the refresh token to a dedicated endpoint on the server to request a new access token.
    The server validates the refresh token, and if valid, issues a new access token (and optionally a new refresh token).

  5. Token Rotation:
    For enhanced security, some implementations rotate the refresh token on each use, issuing a new refresh token along with the new access token. This ensures that even if a refresh token is intercepted, it becomes invalid once it’s used.

  6. Logout and Token Revocation:
    When the user logs out, the server can invalidate the refresh token, ensuring that it can no longer be used to obtain new access tokens.

What is an interceptor in Axios & why is it used in the token process?

The interceptor layer in axios is a powerful feature that allows you to globally handle requests and responses, modifying them before they are sent or after they are received. This is particularly useful for managing authentication tokens, handling errors, and implementing retries for failed requests.

Request Interceptors
Request interceptors allow you to modify the outgoing request before it is sent to the server. This is where you can add authentication tokens to the headers of your requests.

Case 1: When you are storing access token somewhere in your react application


axios.interceptors.request.use(
    (config) => {
        const token = accessToken; // Get the access token from your state or context or session storage
        if (token) {
            config.headers.Authorization = `Bearer ${token}`;
        }
        return config;
    },
    (error) => {
        // Handle request error
        return Promise.reject(error);
    }
);
Enter fullscreen mode Exit fullscreen mode

Case 2: Storing token in cookies

The request interceptor does not need to add the access token explicitly since cookies are automatically included in requests.

Just add below logic to you service layer where you are creating axios instance.

import axios from 'axios';

// Configure axios to include cookies with each request
axios.defaults.withCredentials = true;

Enter fullscreen mode Exit fullscreen mode

Response Interceptors

Response interceptors allow you to handle the response from the server before it reaches your application code. This is useful for handling errors globally, such as refreshing tokens when a request fails due to an expired token.

This way of creating a new access Token & passing the same in the original request & calling the original request again works when we have an end point example "/refresh" which is responsible for giving a new access token.

axios.interceptors.response.use(
    (response) => {
        // Return the response if it's successful
        return response;
    },
    async (error) => {
        const originalRequest = error.config;

        // Check if the error is due to an unauthorized request
        if (error.response.status === 401 && !originalRequest._retry) {
            originalRequest._retry = true; // Prevent infinite loop
            try {
                // Attempt to refresh the access token
                const response = await axios.post('http://localhost:4000/refreshtoken', { token: refreshToken });
                const newAccessToken = response.data.accessToken;
                setAccessToken(newAccessToken); // Update your state with the new access token

                // Update the original request's Authorization header
                originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;

                // Retry the original request with the new token
                return axios(originalRequest);
            } catch (tokenRefreshError) {
                // Handle token refresh failure (e.g., logout the user)
                console.error('Token refresh failed', tokenRefreshError);
                return Promise.reject(tokenRefreshError);
            }
        }

        // Handle other errors
        return Promise.reject(error);
    }
);


Enter fullscreen mode Exit fullscreen mode
  • The first function simply returns the response if it is successful.

The second function handles errors:

  • It checks if the error status is 401 (Unauthorized) and whether the request has not been retried already.
  • If true, it sets _retry to true to prevent an infinite loop.
  • It attempts to refresh the access token by making a request to the token endpoint.
  • If the token refresh is successful, it updates the access token in the state and modifies the original request's Authorization header.
  • It then retries the original request with the new token.
  • If the token refresh fails, it handles the error accordingly, such
  • as logging out the user or showing an error message.

When using HTTP cookies for both access Token & refresh Token

In a scenario where both the access and refresh tokens are passed in HTTP cookies, the backend can manage token validation and renewal. Here's how it works:

  1. Access Token Validation: The backend first validates the access token. If the access token is valid, it processes the request and returns the appropriate response to the frontend.

  2. Access Token Renewal: If the access token is not valid (e.g., expired), the backend checks the refresh token from the cookies. If the refresh token is valid, it generates a new access token.

  3. Continue with Original Request: The backend then uses the new access token to proceed with the original API call and returns the response to the frontend, along with the new access token stored in the HTTP cookies.

This approach ensures a seamless user experience by handling token management on the server side, reducing complexity on the frontend.

How to store tokens?

Method 1: Using local Storage to save the access token & refresh token.

Consider below logic where we have /login end point & we pass username & password.
After successful authentication from backend we get pair of token access token & Refresh token & store them in localStorage

Frontend

  const handleLogin = async () => {
    try {
      const response = await axios.post('http://localhost:4000/login', { username, password });
      localStorage.setItem('accessToken', response.data.accessToken);
      localStorage.setItem('refreshToken', response.data.refreshToken);
    } catch (error) {
      console.error('Login error:', error);
    }
  };

Enter fullscreen mode Exit fullscreen mode

Consider the below code where the Node Server at first generates the set of tokens & then passes them in response to the front end.

Backend

app.post('/login', (req, res) => {
  const { username, password } = req.body;
  if (users[username] !== password) {
    return res.sendStatus(403);
  }

  const accessToken = jwt.sign({ username }, ACCESS_TOKEN_SECRET, { expiresIn: '15m' });
  const refreshToken = jwt.sign({ username }, REFRESH_TOKEN_SECRET);

  refreshTokens.push(refreshToken);
  res.json({ accessToken, refreshToken });
});

Enter fullscreen mode Exit fullscreen mode

Method 2: Using in-memory variable or state to save the access token & refresh token, using Context API to create a context & use it across the Application.

Frontend

import React, { useState, useContext } from 'react';
import axios from 'axios';
import AuthContext from './AuthContext';

const Login = () => {
  const { setAuthState } = useContext(AuthContext);
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');

  const handleLogin = async () => {
    try {
      const response = await axios.post('http://localhost:4000/login', { username, password });
      setAuthState({
        accessToken: response.data.accessToken,
        refreshToken: response.data.refreshToken,
      });
    } catch (error) {
      console.error('Login error:', error);
    }
  };

return (
    <div>
      <input type="text" placeholder="Username" onChange={(e) => setUsername(e.target.value)} />
      <input type="password" placeholder="Password" onChange={(e) => setPassword(e.target.value)} />
      <button onClick={handleLogin}>Login</button>
    </div>
  );
};

export default Login;

Enter fullscreen mode Exit fullscreen mode
  • Consider that you have created a separate AuthContext & using that context to store the token

  • Adding AuthContext for reference


import React, { createContext, useState, useEffect } from 'react';
import axios from 'axios';

const AuthContext = createContext();

export const AuthProvider = ({ children }) => {
  const [authState, setAuthState] = useState({
    accessToken: null,
    refreshToken: null,
  });

  useEffect(() => {
    // Initialize axios interceptors here
    const api = axios.create({
      baseURL: 'http://localhost:4000',
    });

    api.interceptors.request.use(
      (config) => {
        if (authState.accessToken) {
          config.headers['Authorization'] = `Bearer ${authState.accessToken}`;
        }
        return config;
      },
      (error) => {
        return Promise.reject(error);
      }
    );

    api.interceptors.response.use(
      (response) => {
        return response;
      },
      async (error) => {
        const originalRequest = error.config;
        if (error.response.status === 401 && !originalRequest._retry) {
          originalRequest._retry = true;
          const response = await axios.post('http://localhost:4000/token', {
            token: authState.refreshToken,
          });
          setAuthState({
            ...authState,
            accessToken: response.data.accessToken,
          });
          originalRequest.headers['Authorization'] = `Bearer ${response.data.accessToken}`;
          return api(originalRequest);
        }
        return Promise.reject(error);
      }
    );

    // Save the axios instance to the context state
    setAuthState((prevState) => ({
      ...prevState,
      api,
    }));
  }, [authState.accessToken, authState.refreshToken]);

  return (
    <AuthContext.Provider value={{ authState, setAuthState }}>
      {children}
    </AuthContext.Provider>
  );
};

export default AuthContext;


Enter fullscreen mode Exit fullscreen mode
  • Just Make sure to wrap App with AuthProvider.

Method 3: Using Cookies to store token [Best & recommended way]

Certainly! If you prefer not to store tokens in local storage, also not in the application memory. You can use HTTP-only cookies for storing tokens, which provides enhanced security. HTTP-only cookies are not accessible via JavaScript, which reduces the risk of XSS (Cross-Site Scripting) attacks.

Backend


app.post('/login', (req, res) => {
  const { username, password } = req.body;
  if (users[username] !== password) {
    return res.sendStatus(403);
  }

  const accessToken = jwt.sign({ username }, ACCESS_TOKEN_SECRET, { expiresIn: '15m' });
  const refreshToken = jwt.sign({ username }, REFRESH_TOKEN_SECRET);

  res.cookie('accessToken', accessToken, { httpOnly: true, secure: true, sameSite: 'Strict' });
  res.cookie('refreshToken', refreshToken, { httpOnly: true, secure: true, sameSite: 'Strict' });
  res.sendStatus(200);
});

Enter fullscreen mode Exit fullscreen mode

Frontend

  const handleLogin = async () => {
    try {
      await axios.post('http://localhost:4000/login', { username, password }, { withCredentials: true });
    } catch (error) {
      console.error('Login error:', error);
    }
  };

Enter fullscreen mode Exit fullscreen mode

Note: withCredentials: true is used to add all cookies to the network call.

Configure axios to include cookies with each request

axios.defaults.withCredentials = true;

Enter fullscreen mode Exit fullscreen mode

Method 4: Using Cookies to store Refresh token & Passing access token in response body [ Mostly used & recommended way].

app.post('/login', (req, res) => {
    // Destructuring username & password from body
    const { username, password } = req.body;

    // Checking if credentials match
    if (username === userCredentials.username &&
        password === userCredentials.password) {

        //creating a access token
        const accessToken = jwt.sign({
            username: userCredentials.username,
            email: userCredentials.email
        }, process.env.ACCESS_TOKEN_SECRET, {
            expiresIn: '10m'
        });
        // Creating refresh token not that expiry of refresh 
        //token is greater than the access token

        const refreshToken = jwt.sign({
            username: userCredentials.username,
        }, process.env.REFRESH_TOKEN_SECRET, { expiresIn: '1d' });

        // Assigning refresh token in http-only cookie 
        res.cookie('jwt', refreshToken, {
            httpOnly: true,
            sameSite: 'None', secure: true,
            maxAge: 24 * 60 * 60 * 1000
        });
        return res.json({ accessToken });
    }
    else {
        // Return unauthorized error if credentials don't match
        return res.status(406).json({
            message: 'Invalid credentials'
        });
    }
})

Enter fullscreen mode Exit fullscreen mode
  • Here we are passing accessToken in response & adding refresh token in HTTP cookies

  • Client will be able to read the access token from the response body & can store it anywhere like session storage / local storage / in memory etc.

  • now client will pass this access token to all requests in authorization header in request interceptors

Here, the same use case is applicable where we can handle stuff in response interceptor & where we can call additional endpoint to get refresh token in case of token expiry or

  • Refresh page case where no access token in present but we have refresh token in HTTP cookies.

Read all article related to system design with hash tag

SystemDesignWithZeeshanAli

PLEASE LET ME KNOW VIA COMMENTS IF I HAVE MISSED ANYTHING OR IF ANYTHING IS WRONG HERE

Top comments (0)