Choosing Where to Store JWT Tokens on the Client Side

In a previous piece, I covered the fundamentals of OAuth 2.0, including the generation of access and refresh tokens. This time, the focus shifts to the client-side storage of those tokens.

Access tokens are typically short-lived JWT tokens signed by your backend and attached to every HTTP request for authorization. In contrast, refresh tokens are long-lived, opaque strings kept in your database, used solely to obtain a new access token once the old one expires.

Where Should Tokens Be Stored on the Front-End?

Two primary storage locations dominate the discussion: localStorage and cookies. While both have their proponents, the security community generally favors cookies, viewing them as the more secure option.

Let's compare localStorage and cookies directly. This analysis draws heavily from a well-known article and its subsequent discussion.

Local Storage

LocalStorage vs. Cookies: All You Need to Know About Storing JWT Tokens Securely in the Front-End — figure 1

Pros: Convenience is its main selling point.

  • It's a purely JavaScript-based API, which is incredibly handy when you lack a dedicated backend and rely on third-party APIs where you can't dictate cookie policies.
  • It works seamlessly with APIs that require an authorization header, such as Authorization Bearer ${access_token}.

Cons: It's an open book for XSS attacks.

An XSS vulnerability allows an attacker to execute arbitrary JavaScript on your site. Once that happens, they can simply read the access token directly from localStorage. This threat is realistic since almost every site includes third-party scripts (e.g., React, Vue, jQuery, Google Analytics) which could become the attack vector.

httpOnly Cookies

LocalStorage vs. Cookies: All You Need to Know About Storing JWT Tokens Securely in the Front-End — figure 2

Pros: JavaScript can't touch them, making them resistant to XSS.

  • Setting the httpOnly and secure flags ensures the cookie is sent to the server but is completely inaccessible to JavaScript running in the browser. An attacker who injects script into your page cannot read the token from this cookie.
  • The browser automatically attaches the cookie to every HTTP request sent to your domain.

Cons: Cookies aren't always a viable option.

  • There is a hard 4KB size limit on cookies. If your JWT is large, it won't fit. This is a significant constraint.
  • Cookies are tied to the domain. If your API is on a different domain and can't accept cookies, or if it strictly requires an authorization header, this approach falls apart.

Analyzing the XSS Threat

LocalStorage vs. Cookies: All You Need to Know About Storing JWT Tokens Securely in the Front-End — figure 3

Storing tokens in localStorage is risky because a single XSS flaw can let an attacker steal the token for later use. However, choosing httpOnly cookies doesn't make XSS attacks impossible; it just changes how they play out. An attacker can still execute code on your page and, in doing so, simply make an HTTP request from the victim's browser, causing the request to include the cookies automatically. While they can't read the token itself, they rarely need to. Using the victim's browser to send the authenticated request is often just as effective, if not more effective, than doing it from their own machine.

Dealing with CSRF Attacks

CSRF attacks trick a user's browser into sending an unwanted request to a site where they are authenticated. For instance, if a site accepts an email change via:

POST /email/change HTTP/1.1 
Host: site.com 
Content-Type: application/x-www-form-urlencoded 
Content-Length: 50 
Cookie: session=abcdefghijklmnopqrstu 

email=myemail.example.com

An attacker can craft a malicious HTML form on their site that auto-submits a POST request to https://site.com/email/change with a hidden email field. The user's session cookie is sent along automatically, making it look like a legitimate request.

The good news is that this is largely preventable. You can set the sameSite attribute on your cookie and use an anti-CSRF token to validate that requests originate from your own site.

Opting for Cookies Over localStorage

While not perfect, cookies are the safer choice when circumstances permit. Here's the case for that position:

  • XSS vulnerabilities are a threat in both scenarios, but httpOnly cookies raise the bar significantly, making the exploitation much more difficult.
  • CSRF attacks are a concern with cookies, but they can be effectively neutralized with the sameSite flag and anti-CSRF tokens.
  • If you need to use the Authorization: Bearer header or have a JWT larger than 4KB, using a cookie-based refresh token is a viable path forward.

This logic aligns with the official stance from OWASP:

Avoid storing session identifiers in local storage because JavaScript can always access them. Cookies can reduce this risk if you use the httpOnly flag.
OWASP: HTML5 Security Cheat Sheet

LocalStorage vs. Cookies: All You Need to Know About Storing JWT Tokens Securely in the Front-End — figure 4

How to Implement Token Persistence with Cookies

To summarize, here are the typical approaches to token storage:

  • Option 1: Keep the access token in localStorage (and possibly the refresh token there too, or in an httpOnly cookie). This exposes the access token to XSS theft.
  • Option 2: Put both access and refresh tokens in httpOnly cookies. This reduces XSS exposure but introduces a CSRF risk that must be mitigated.
  • Option 3: Store the refresh token in an httpOnly cookie. This is the most robust option, offering a good balance of security. The access token is held elsewhere.

Option 3 is widely considered the strongest of the three, so we'll focus on it.

Storing an access token "in memory" means placing its value in a client-side variable, like const accessToken = XYZ, rather than in the persistent localStorage or cookies.

Why does this defend against CSRF?

While an attacker could trick a user's browser into submitting a form to /refresh_token to get a new token, they cannot read the response from that HTML form. To pull off a successful attack, they'd need to use fetch or AJAX and then read the response. However, this requires the Authorization Server to have proper CORS policies in place that block unauthorized origins, making it impossible for the attacker's script to read the data.

How does this setup work?

Step 1: Issue Both Tokens During Authentication.

Upon successful login, the Authorization Server sends back an access_token in the response body and sets a refresh_token as an httpOnly cookie.

For the refresh token cookie, ensure it has these attributes:

  • The httpOnly flag to block any JavaScript access.
  • The secure=true flag to enforce transmission over HTTPS only.
  • The SameSite=strict flag to prevent CSRF, whenever possible. This requires the API server to be on the same site as the front-end. If that isn't possible, the server needs to implement proper CORS and other safeguards.

Step 2: Keep the Access Token in Memory

Your client-side code should hold the access token in a variable (const accessToken = xyz). The token will be lost on page refresh. That is perfectly fine; it's precisely the job of the refresh token to replace it. The trade-off is between the short-lived inconvenience of losing the in-memory token and the much greater security risk of storing it in a place that malicious scripts can easily scrape.

Step 3: Refresh the Access Token When Needed

When your in-memory access token is lost or expires, you call the /refresh_token endpoint. The refresh token is automatically attached to this request via the cookie. The server responds with a fresh access token, which you can then use for the subsequent API calls. This configuration also frees you from the cookie's 4KB size limit since the main access token is not stored in a cookie, and you can use the Authorization header freely.

Wrap-Up

That should give you a solid foundation for securely managing tokens on the client side.


Further Reading

This article was written with help from several key resources:


Get in Touch

We welcome your comments and questions below.