JavascriptD_870

Should you use localStorage or cookies to store your authentication token? What are the tradeoffs to each approach and how can you tie them all together? Did you know that localStorage can even be used to synchronize logout across multiple tabs?

Browser Storage

In React applications, we typically store our data in state, either using component state or a state management tool such as Redux and MobX. This works great until the user refreshes the page. Then we have to start from scratch and re-fetch all the data from our server in order to populate the state again.

There are many situations where we might want to persist data in such a way that when the user refreshes the page, all is not lost. Imagine a world where the user had to re-authenticate every time they refreshed the page!

In this this two-part series, we will look at four different ways to store data in the browser: localStorage, sessionStorage, cookies, and IndexedDB. Why so many ways? They each are useful in different circumstances and have different capabilities. In this article we will explore how best to take advantage of them in a note-taking application which includes JWT authentication (with synchronized logout) along with “draft” notes that can be synchronized to the server at a later date.

The source code for this project can be found at https://github.com/leighhalliday/leigh-notes-web. The final version of this app is located at https://leigh-notes-web.herokuapp.com/.

Client-Side Storage Overview

  • localStorage: A key/value store that lives entirely in the client (browser). Useful for storing authentication tokens which do not need to be sent to the server - when no server-side rendering (SSR) is used.
  • sessionStorage: A key/value store which functions similarly to localStorage, but is expired/cleared when the user closes the page and is not shared across tabs even on the same domain. Most useful for storing temporary data.
  • cookies: Data which can be read from and written to within the browser, but also travels to the server with each request in the cookie header.
  • IndexDB: As the name suggests, a database that lives within the browser. Somewhat difficult to use on its own, but paired with a tool such as PouchDB, can be useful for storing more complex client-side data which needs to be queried and be performant.

localStorage and sessionStorage

Both localStorage and sessionStorage are key/value stores that use the Storage object. Both the key and the value must be strings.

// Set an item
localStorage.setItem("name", "Leigh");

// Get an item
localStorage.getItem("name"); // "Leigh"

// If we look at `localStorage` we see:
// Storage {name: "Leigh", length: 1}

// We can remove an individual item:
localStorage.removeItem("name");

// Or we can clear all items:
localStorage.clear();

I mentioned that both the key and the value must be strings… so how do we store an array or an object? We can do this using JSON.stringify(object) to convert our JS object into a JSON string when setting the item, and use JSON.parse(string) to convert the JSON string back into a JS object when getting the item. Take a look at the example below:

let user = { name: "Leigh", email: "leigh@email.com" };

// Must stringify before putting object into storage
localStorage.setItem("user", JSON.stringify(user));

// Must parse when reading string value from storage
user = JSON.parse(localStorage.getItem("user"));

If you ever read an item from localStorage and it looks like "[object Object]," it’s a sign that you forgot to stringify the value first!

Cookies

If your app is a fully client-side SPA (single-page application), you probably don’t need to mess with cookies, and localStorage will do the trick. That said, if you’re using something like Next.js to support server-side rendering (SSR), cookies become very important, giving you access to the authentication token on the server.

We typically think of cookies in the plural, but the truth is that they are stored in a single string value that must be parsed to break them into individual key/value pairs.

console.log(document.cookie);
// "AMCV_A783776A5245B1E50A490D44%40AdobeOrg=2121618341%7CMCIDTS%7C17913%7CMCMID%7C60111815297468236860711566255111819255%7CMCAAMLH-1548272641%7C9%7CMCAAMB-1548272641%7CRKhpRz8krg2tLO6pguXWp5olkAcUniQYPHaMWWgdJ3xzPWQmdj0y%7CMCOPTOUT-1547675041s%7CNONE%7CMCAID%7CNONE; _gcl_au=1.1.664470253.1547667842; token=eyJhbGciOiJIUzI1NiJ9.eyJ0b2tlbiI6IjY3Njc2N2VkLTFkYTMtNGZjOS05OGYxLTc3YTJlYjUzZGFlOSJ9.0BwIIJdHXmP_MLh5qQjUpdWZ_IQ4bjb6aSsTetLA_N4"

That’s a mess! We can break them apart by splitting the string on "; ", and if we then map each value and split it on "=" we’ll finally end up with the key/value pairs we’re looking for:

const cookies = document.cookie.split("; ").map(value => value.split("="));
// Gives us:
[
  [
    "AMCV_A783776A5245B1E50A490D44%40AdobeOrg",
    "2121618341%7CMCIDTS%7C17913%7CMCMID%7C60111815297468236860711566255111819255%7CMCAAMLH-1548272641%7C9%7CMCAAMB-1548272641%7CRKhpRz8krg2tLO6pguXWp5olkAcUniQYPHaMWWgdJ3xzPWQmdj0y%7CMCOPTOUT-1547675041s%7CNONE%7CMCAID%7CNONE"
  ],
  ["_gcl_au", "1.1.664470253.1547667842"],
  [
    "token",
    "eyJhbGciOiJIUzI1NiJ9.eyJ0b2tlbiI6IjY3Njc2N2VkLTFkYTMtNGZjOS05OGYxLTc3YTJlYjUzZGFlOSJ9.0BwIIJdHXmP_MLh5qQjUpdWZ_IQ4bjb6aSsTetLA_N4"
  ]
];

The last cookie we can see is the token cookie, which stores the JWT (JSON Web Token) required for authentication. The other two cookies… I’m not quite sure where they came from (my localhost:3000 has seen some crazy things)!

Reading and writing cookies in this format - we haven’t even covered expiry and encoding/decoding values - is a pretty big pain and why a small library such as js-cookie can make your life way easier.

With js-cookie we can get and set cookies in a similar way to how we did it with localStorage:

// set a value
jsCookie.set("name", "Leigh");

// get a value
jsCookie.get("name"); // "Leigh"

// remove cookie
jsCookie.remove("name");

It should be mentioned that certain cookies may be marked as “HTTP Only”, which means that they are not accessible from within the client-side JavaScript code. Cookies can also have an expiry date, be limited to a certain subdomain and/or path, and only be available on secure (HTTPS) connections.

Authentication Using a JWT

With our newfound knowledge of localStorage, sessionStorage, and cookies, let’s implement a login component which will make an HTTP request to the server with the user’s email and password, receiving a response with a JWT token if valid. When we have the JWT token, we’ll need to store it somewhere. Now is when you need to choose between localStorage or cookies, but because we are doing this from within a Next.js application which supports SSR, we’ll be required to opt for cookie storage so that it will be available to the server.

To better encapsulate our authentication logic, we’ll create a custom hook called useAuth, responsible for making the HTTP request and tracking what the user has entered into the form.

// In pages/login.js

const useAuth = () => {
  // State required to track the form's data
  const [submitting, setSubmitting] = useState(false);
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");

  // A function to call when the user is ready to authenticate
  // the values they have entered into the form.
  const authenticate = async event => {
    event.preventDefault();
    setSubmitting(true);
    setError("");

    try {
      // Make an HTTP request to server
      const response = await Axios.post(
        "https://leigh-notes-api.herokuapp.com/session",
        {
          email,
          password
        }
      );
      // Pass the token to a `login` function which is responsible
      // for saving its value in a cookie and redirecting the user.
      const { token } = response.data;
      login({ token });
    } catch (error) {
      // A 400 means invalid email or password, update state so user
      // can see the error message.
      if (error.response && error.response.status === 400) {
        setError(error.response.data.errors.join(", "));
      } else {
        throw error;
      }
    }

    setSubmitting(false);
  };

  return {
    submitting,
    email,
    setEmail,
    password,
    setPassword,
    error,
    authenticate
  };
};

So what does the login function that is called look like? It’s very short, working with js-cookie and the Next.js routing system to redirect the user. In our app this function is exported from utils/withAuthSync.js.

// In utils/withAuthSunc.js
import Router from "next/router";
import cookie from "js-cookie";

export const login = async ({ token }) => {
  cookie.set("token", token, { expires: 1 }); // cookie expires in 1 day
  Router.push("/");
};

Let’s finally take a look at the Form component which works hand-in-hand with the functions already shown above.

const Form = () => {
  const {
    submitting,
    email,
    setEmail,
    password,
    setPassword,
    error,
    authenticate
  } = useAuth();

  return (
    <form onSubmit={authenticate}>
      {error && <p className="error">{error}</p>}
      <input
        type="email"
        placeholder="email"
        onChange={e => {
          setEmail(e.target.value);
        }}
        value={email}
        autoComplete="username"
        disabled={submitting}
        required
      />
      <input
        type="password"
        placeholder="password"
        onChange={e => {
          setPassword(e.target.value);
        }}
        value={password}
        autoComplete="current-password"
        disabled={submitting}
        required
      />
      <button type="submit" disabled={submitting}>
        Login
      </button>
    </form>
  );
};

Logging User Out

We’ve logged the user in, but how do we log the user out? Deleting the cookie might be the obvious answer, but the truth is that in our case it is only 1/4 correct. We’re going to perform the following actions when the user asks to be logged out:

  1. Remove the token cookie.
  2. Tell the API to expire the token… this is important so that even if someone were to find the JWT token, the server wouldn’t acknowledge it.
  3. We’ll use localStorage to synchronize the user’s logged-out status across multiple tabs in the browser. They will all be redirected back to the login page and we won’t be left exposing the user’s sensitive data on the page in plain sight.
  4. Finally, redirect user to the /login page.
// utils/withAuthSync.js
import Router from "next/router";
import cookie from "js-cookie";
import Axios from "axios";

export const logout = async token => {
  // 1. Remove the token cookie
  cookie.remove("token");

  // 2. Notify API to expire token
  // Notice we are sending the token in the Authorization header
  // so that the server knows which token to expire
  try {
    await Axios.delete("https://leigh-notes-api.herokuapp.com/session", {
      headers: { Authorization: `Bearer: ${token}` }
    });
  } catch (error) {
    // assume already logged out if call fails
  }

  // 3. Write to localStorage... triggering an event which enables
  // listening tabs to redirect to the /login page
  window.localStorage.setItem("logout", Date.now().toString());

  // 4. Redirect user back to the /login page
  Router.push("/login");
};

The Layout component, which is in charge of rendering the appropriate navigation based on whether the user has a token or not (logged in or logged out), calls the logout function when the user clicks the button.

export default function Layout({ token, children }) {
  return (
    <div>
      <Head>
        <title>Notes!</title>
      </Head>
      <nav>
        {token ? (
          <button onClick={() => logout(token))}>Logout</button>
        ) : (
          <>
            <Link href="/login">
              <a className="btn">Login</a>
            </Link>
            <Link href="/register">
              <a className="btn">Register</a>
            </Link>
          </>
        )}
      </nav>
      <div className="container">{children}</div>
    </div>
  );
}

Storage Events - Synchronize Across Browser Tabs

We saw above something pretty neat, that localStorage allows us to listen for events any time a value is set. In our app’s case, all tabs open for this app can listen for when localStorage is written to using the “logout” key. All pages in our app that require authentication are wrapped in a higher-order-component (HOC) which listens for this event.

export const withAuthSync = WrappedComponent => {
  return class extends Component {
    // I have removed some Next.js specific code to simplify things
    // for the purposes of this article

    componentDidMount() {
      window.addEventListener("storage", this.syncLogout);
    }

    componentWillUnmount() {
      window.removeEventListener("storage", this.syncLogout);
      window.localStorage.removeItem("logout");
    }

    syncLogout = event => {
      if (event.key === "logout") {
        console.log("logged out from storage!");
        Router.push("/login");
      }
    };

    render() {
      return <WrappedComponent {...this.props} />;
    }
  };
};

The syncLogout function is called and we are able to check if event.key (referring to the localStorage key) is equal to "logout", and if so we can use the Next.js router to send the user to the /login page. It seems that this StorageEvent is not triggered from within the same browser tab that it was declared in, but that’s perfect for our use-case.

The HOC’s use looks like export default withAuthSync(Index);.

Conclusion

So far we’ve looked in-depth at localStorage (along with its closely related cousin sessionStorage) and cookies. The former works great when you need a key/value store entirely client-side, with cookies allowing storage to be accessible both client-side but also server-side.

There comes a time when you might feel the restrictions of these two approaches. They both are written to and read from synchronously, potentially causing browser slow-downs, and something just doesn’t feel right about using localStorage as a “database.” That’s where IndexDB comes into the picture, providing asynchronous client-side storage, albeit with a somewhat clumsy interface.

Next, keep reading with Part 2 of this article, which will show how you can easily use the PouchDB library to interact with IndexDB.

 

For More Info on Building Great Web Apps

Want to learn more about creating great web apps? It all starts out with Kendo UI - the complete UI component library that allows you to quickly build high-quality, responsive apps. It includes everything you need, from grids and charts to dropdowns and gauges.

Learn More about Kendo UI

Get a Free Trial of Kendo UI


leigh-halliday
About the Author

Leigh Halliday

Leigh Halliday is a full-stack developer specializing in React and Ruby on Rails. He works for FlipGive, writes on his blog, and regularly posts coding tutorials on YouTube.

Related Posts

Comments

Comments are disabled in preview mode.