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?
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/.
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!
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.
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>
);
};
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:
// 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>
);
}
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);
.
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.
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.