Authenticate with cookies on React — React Training

I propose today to discover how to set up an authentication system within a React application. Our objective will be the creation of a hook useAuth() which will allow us to retrieve the authentication state of the user?

Long live cookies!

When talking about authentication it is often mentioned the use ofaccess token or of JWT token. While these techniques are viable in some situations, in most cases they are more complex than necessary.

  • JWTallows to authenticate the user in a stateless way, which allows APIs to validate the identity of the user without having to contact the server which contains the identities (if on the server side you retrieve the user on each request, you use wrong the JWT token).
  • Access Token / Auth Tokenwill mostly be useful outside of the browser context (mobile/desktop app) which doesn’t handle cookie logic.

Since browsers support cookies, you might as well take advantage of them, especially since it offers several advantages:

  • Cookies httpOnly are secure by default and will not be accessed by malicious JavaScript code (thus reducing attack vectors for token theft).
  • The browser manages their invalidation natively
  • The server can, during a response, redefine the cookie to refresh the information if necessary.

The Big Bad CORS

In general, the application and the API are not located on the same domain, and our requests are cross-origin requests for which the sharing of resources is limited by the CORS policies of the browser. It is possible to use cookies but this requires a good understanding of the situation and the configuration to be made.

On the front side, you will have to ask, during a fetch request, to include the identifiers.

fetch('https://domain.ltd', {
  credentials: 'include',
  // ...
})

Then, on the server side, it will have to respond correctly by returning the correct headers.

  • Access-Control-Allow-Credentials should be at true.
  • Access-Control-Allow-Origin will have to specifically accept the domain of our front application (must not be a wildcard *).
  • Access-Control-Allow-Methods should contain the methods you want to use.

Finally, on the cookie side, you will need to have the right attributes:

  • If your 2 applications (api & front) are on the same main domain (eTLD+1), there is nothing special to do the cookie can even have a SameSite: Strict.
  • If your 2 applications do not share a primary domain then the cookie must have a SameSite: none and be Secure so that the browser agrees to honor the headers related to cookies.

To learn more about how SameSite I refer you to the specifications and to better understand the browser policy in the web.dev article.

React side

Now that the server configuration is done, we are ready to tackle the React part. We first start by thinking about what we want to obtain (how the hook works) before attacking the code. To unroll it from our application, we need to know the authentication status of the user.

const {
    status,       // Contient le status de l'authentification
    authenticate, // Demande le status d'authentification
    login,        // Tente de connecter l'utilisateur
    logout,       // Déconnecte l'utilisateur
} = useAuth()

In the case of authentication by cookie httpOnly one cannot initially determine whether the user is authenticated or not without contacting the server. We therefore end up with 3 possible states.

export enum AuthStatus {
  Unknown,
  Authenticated,
  Guest,
}
  • Unknownbefore contacting the server we do not yet know the user’s authentication status, we do not know if he is connected or not.
  • Authenticatedthe user is successfully authenticated.
  • Guestthe server replied to us is the user is not authenticated.

To store user information we are going to need a store which will be shared by all the components. For this we can use a context or rely on a library to centralize things like jotai or zustand.

import { create } from "zustand";
import { combine } from "zustand/middleware";
import { Account } from "./types.ts";

export const useAccountStore = create(
  combine(
    {
      account: undefined as undefined | null | Account,
    },
    (set) => ({
      setAccount: (account: Account | null) => set({ account }),
    })
  )
);

And we can use this store in our hook and create the different methods to manage the basic operations.

import { Account } from "../types.ts";
import { useAccountStore } from "../store.ts";
import { useCallback } from "react";
import { apiFetch } from "../utils/api.ts";

export function useAuth() {
  const { account, setAccount } = useAccountStore();
  let status;
  switch (account) {
    case null:
      status = AuthStatus.Guest;
      break;
    case undefined:
      status = AuthStatus.Unknown;
      break;
    default:
      status = AuthStatus.Authenticated;
      break;
  }

  const authenticate = useCallback(() => {
    apiFetch<Account>("/me")
      .then(setAccount)
      .catch(() => setAccount(null));
  }, []);

  const login = useCallback((username: string, password: string) => {
    apiFetch<Account>("/login", { json: { username, password } }).then(
      setAccount
    );
  }, []);

  const logout = useCallback(() => {
    apiFetch<Account>("/logout", { method: "DELETE" }).then(setAccount);
  }, []);

  return {
    status,
    authenticate,
    login,
    logout,
  };
}

Now, when loading the application, we need to know if the user is authenticated or not. On our root component (which has never been reassembled) we can launch the authentication using the method authenticate.

function App() {
  const { status, authenticate } = useAuth();

  useEffect(() => authenticate(), [])

  if (status === AuthStatus.Unknown) {
    return <Loader />;
  }

  if (status === AuthStatus.Guest) {
    return <Login />
  }

  return <Dashboard />
}

And There you go ! You can also create a hook to retrieve user information that can only be used inside authenticated components.

import { useAuth } from "./useAuth.ts";

export function useAccount() {
  const { account } = useAccountStore();

  if (!account) {
    throw new Error("User is not authenticated");
  }

  return {
    account,
  };
}

This hook can integrate additional methods depending on your needs. But you can also create more specific hooks according to your needs.

Improve initial loading

The main problem with our approach, and cookies httpOnly is that the application must first contact the server to know the level of authentication before doing anything. This adds extra delay to the app display. This problem can be remedied by being “optimistic” about the authentication state of the user.

We start by persisting the user in the localStorage (in the case of zustand we just need to add the middleware persist). We also change the default value of account to put null (in the absence of localStorage, it is considered that the user is not connected).

import { create } from "zustand";
import { combine, persist } from "zustand/middleware";
import { Account } from "./types.ts";

export const useAccountStore = create(
  persist(
    combine(
      {
        account: null as undefined | null | Account,
      },
      (set) => ({
        setAccount: (account: Account | null) => set({ account }),
      })
    ),
    { name: "account" }
  )
);

Now, when the user logs in, in addition to the cookie, we will add the information to the localStorage. If the user reloads the application, his information will be retrieved from the storage, and he will be considered connected by default (instead of the state Unknown).

On the other hand, a desynchronization can take place if the cookie expires for example and the information remains in the localStorage.

  • The application considers the user as logged in (because of the localStorage)
  • The API will return errors because the user is no longer logged in.

In this situation, one can tap into API returns to destroy the authentication state in the event of an unauthorized return.

const r = await fetch(/* ... */*)
if (!r.ok && r.status === 401) {  
    localStorage.removeItem("account");  
    window.location.reload();
}