Auth State in React with localStorage

Note: using localStorage for auth state iscontroversial. However, in a stateless API environment that does not use HttpOnly cookies, options are very limited. If you go this route, make sure you understand the risk of a cross site scripting attack.

You can find a companion Github repo for this tutorial here.

Recently I took on a project to write a front end in React in which my access to the backend is purely through a stateless web API - no direct database access, no server-set cookies, no server side processes at all beyond serving the initial HTML and JavaScript bundle. While I’ve written scads of web API integrations before, this is the first time I’ve had to do a complete app this way.

The first challenge was user authentication. I needed a way to maintain user state locally, and if there is no logged in user, redirect the user to a login page.

Additionally, after login the user needs a token to access the API for fetching and updating other objects. Crucially, the auth token must be refreshed every 10 minutes.

This post will walk you through the code to do that. I’m not going to show the creation of a login form since there are many other tutorials on that. I’m just going to show the data structures and functions I used to set and track user state.

The API Library

First I created a library that handles all API access. This is a single file called api.ts that has all the GET and POST requests to the web API. Putting it in one place makes it easier to maintain and extend functionality as requirements change.

Every API endpoint is on a host that’s set in an environment variable, and they all look like this:

{{domainname}}/api/path/to/resource

The first thing I need is a function that generates a the url for a given resource. I’m using the Vite build library, so the environment variable follows that convention. The endpoint parameter is a string that represents the requested resource without all the domain and protocol info.

//.env
VITE_APP_API_URL = https://example.com/api/

//api.ts
function apiUrl(endpoint = ""): string {
  return import.meta.env.VITE_APP_API_URL + endpoint;
}

To login a user, the full endpoint is https://example.com/api/auth/login/, so in my code the login action makes the following function call: apiUrl('auth/login/')

Logging in requires an email address and a password. Below is what the initial login function looks like. For now we just want to return the user object if it succeeds, or boolean false if not. The web API returns a 401 error on failure, so we will need to use a try/catch block to handle the response.

Note: In this app I am using theAxioshttp client. Adjust as necessary if you are using a different library.

//api.ts
import axios from 'axios'

export async function login(email: string, password:string) {
  const url = apiUrl('auth/login/');
  try {
    const response = await axios.post(url, { email, password })
    return response.data;
  } catch (error: any | unknown){
      // login failure returns a 401 error code
      // catch it here and return boolean
      // if is NOT a 401, throw error
     if (axios.isAxiosError(error)) {
      if(401 === error.response?.status) {
        return false;
      } 
    }
    throw error;
  }
}

The User Object

After login, we need to store the user object locally to reference when determining whether a user can view a protected page. We also need to know what time the login was done so we can determine if the user object needs to be refreshed. Here is an example of the user object returned by the API. access is a token that we’ll use to access other API resources, and refresh is a token that’s used to refresh the user auth state.

{
    id: 123,
    email: "[user@example.com](<mailto:user@example.com>)",
    access: "xyzabce123",
    refresh: "abcxyz123"
}

Let’s define a type for handling this object shape:

//api.ts
export type LoginUser = {
  id: number;
  email: string;
  access: string;
  refresh: string
}

After login, we will store this data in the browser’s native localStorage*. Since localStorage only handles strings, we will need JSON.stringify() to write to localStorage, and JSON.parse() to read. To simplify access, let's wrap access to the user object in two functions: setAuthUser and getAuthUser

//api.ts
export function setAuthUser(user: LoginUser): void {
  const jsonUser = JSON.stringify(user);
  localStorage.setItem('user', jsonUser);
}

export function getAuthUser(): LoginUser | null {
  const jsonUser = localStorage.getItem('user');
  if(null !== jsonUser) {
    return JSON.parse(jsonUser);
  }
  return null;
}

With these functions, I can add setAuthUser() to the login() function above or do it as part of the login page's submit action. In my case I included it as part of the login function, but if you need more granularity you should keep it separate.

//api.ts
export async function login(email: string, password:string) {
  const url = apiUrl('auth/login/');
  try {
    const response = await axios.post(url, { email, password })
    //NEW CODE HERE
    setAuthUser(response.data)
    return getAuthUser()
    //END NEW CODE
  } catch (error: any | unknown){
     if (axios.isAxiosError(error)) {
       if(401 === error.response?.status) {
         return false;
       } 
     }
     throw error;
  }
}

Finally, we need a logout function that deletes the user object altogether.

//api.ts

export function logout(){
  localStorage.removeItem('user')
}

Accessing Protected Resources

Now that we have a single source of truth for the user object, we can use the token stored in user.access to fetch other resources from the API by submitting it as a bearer token on every request. Below is a function called authHeaders() that we can call to set up the authorization header, and an example of it’s use in fetching from a resource called “projects” — a list of projects in the database

//api.ts

function authHeaders(): { headers: Record<string, string> } {
  const token = getAuthUser()?.access;
  return { headers: {'Authorization': `Bearer ${token}`} }
}
export async function fetchProjects() {
  const url = apiUrl('projects/');
  const response = await axios.get(url,authHeaders());
  return response.data;
}

Note that the use of bearer token is required by the API provider. They could have used other schemes likeJSON Web Token, an API key or one of the many other auth schemes, which would require a slightly different header.

Protecting Routes in React

We can also use the user object to protect routes with React Router's BrowserRouter. In the example below, we have two unprotected routes: the root landing page and the login page. We use getAuthUser() to get the logged in user and only make protected routes available if the user exists. This example assumes all the files are in the top level of the /src directory.

The last route in the list is a wildcard catch-all route that redirects to the login page.

//App.tsx
import React from 'react';
import { BrowserRouter, Routes, Route} from 'react-router-dom';
import Layout from './layout'
import Home from './home'
import Login from './login'
import Dashboard from './dashboard'
import Projects from './projects'
import AccessDenied from './accessdenied'
import { getAuthUser } from './api';
import type { LoginUser } from './api';

export const App: React.FC = () => {
  const loginUser: LoginUser = getAuthUser()
  return (
      <BrowserRouter>
        <Layout>
          <Routes>
            <Route path="/" element={<Home />} />
            <Route path="/login" element={<Login />} />
            {user &&
              <>
                <Route path="/dashboard" element={<Dashboard />} />
                <Route path="/projects" element={<Projects />} />
              </>
            }
            <Route path="*" element={<Login />} />
          </Routes>                
        </Layout>
      </BrowserRouter>
  )
}

The getAuthUser function can be used in other places as well, such as in the layout to determine which menu items are available, or to show the user’s email address on a profile screen.

Refreshing the User Object

Now that we have the auth structure and route protection in place, we just need a function that refreshes the user object every 10 minutes.

First, let's look at how the API works. It has the following endpoint:

{{domainname}}/api/auth/token/refresh/

...and expects a single field to be submitted in a POST request:

{
    "refresh": "averylongstringoflike200characters"
}

Recall that the user object has a key called refresh. This is what we will submit to the API. Here is a function that submits refresh and updates the user object. If refresh fails with a 401 error, that means the user session is no longer viable and we should immediately logout.

//api.ts
export async function refreshAuth():LoginUser | null {
  const user = getAuthUser();
  if(!user){
    return null
  }
  try {
    const body = {refresh: user.refresh}
    const url = apiUrl('auth/token/refresh/');
    const response = await axios.post(url, body, authHeaders());
    user.refresh = response.data.refresh
    user.access = response.data.access
    setAuthUser(user)
    return getAuthUser();
  } catch (error: any | unknown){
     if (axios.isAxiosError(error)) {
       if(401 === error.response?.status) {
         logout()
         return null
       } 
     }
     throw error;
  }
}

Automatic Refresh

With these functions in place we have all the building blocks necessary to create an automatic refresh every 10 minutes. In the main App file, we set up a function that runs every 10 minutes using setInterval. If the user is not valid, the app automatically re-routes to the login screen. We put the function in a useEffect that runs on initial load.

//App.tsx
import {refreshAuth} from 'api'
useEffect(() => {
    const refreshUser = async () => {
      try {
        const refreshedUser = await refreshAuth();
        if(!refreshedUser){
          navigate('/login')
        }
      } catch(error){
        console.error('Error refreshing authentication:', error);
      }
    };
    // Call the refreshUser function every 10 minutes
    const timer = setInterval(refreshUser, 600000);
    return () => clearInterval(timer);
  }, []);

And that's it! What do you think? This is my first serious React project, so I'm open to suggestions on how to make it better. Leave a comment below.